From c1a6223933e514b6bc052f82c38cc751c687020b Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 24 Aug 2026 17:59:13 -0600 Subject: [PATCH 01/42] Build initial Control server foundation --- .gitignore | 3 + README.md | 78 +++++++++++ pom.xml | 43 ++++++ .../control/ControlApplication.java | 37 ++++++ .../control/domain/InMemoryNodeRegistry.java | 75 +++++++++++ .../control/domain/NodeRegistry.java | 13 ++ .../control/domain/ValidationException.java | 14 ++ .../control/http/ControlHttpServer.java | 124 ++++++++++++++++++ .../control/protocol/ControlIdentity.java | 5 + .../control/protocol/Heartbeat.java | 9 ++ .../control/protocol/NodeRegistration.java | 10 ++ .../control/protocol/NodeStatus.java | 11 ++ .../control/protocol/Protocol.java | 6 + .../control/protocol/ProtocolError.java | 9 ++ .../control/protocol/RequestEnvelope.java | 3 + .../control/protocol/ResponseEnvelope.java | 4 + .../domain/InMemoryNodeRegistryTest.java | 39 ++++++ .../control/http/ControlHttpServerTest.java | 89 +++++++++++++ 18 files changed, 572 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/java/com/bencodez/votingplugin/control/ControlApplication.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/domain/ValidationException.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/ControlIdentity.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/Heartbeat.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/NodeRegistration.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/NodeStatus.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/Protocol.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/ProtocolError.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/RequestEnvelope.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/ResponseEnvelope.java create mode 100644 src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java create mode 100644 src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..16b61c5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target/ +/.idea/ +*.iml diff --git a/README.md b/README.md new file mode 100644 index 00000000..66890b7b --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# VotingPlugin Control + +VotingPlugin Control is the local-first administration service for a VotingPlugin network. This repository currently +contains the first runnable protocol and service foundation—not a VotingPlugin integration or a finished management UI. + +## Architecture and boundaries + +Control is one local application intended to observe and, in later milestones, coordinate multiple BungeeCord and +Velocity proxies and their backend servers. VotingPlugin nodes will connect to Control; Control is never in the vote +processing path. A stopped or unreachable Control instance must therefore have no effect on voting. + +The protocol/domain package has no HTTP, Bukkit, BungeeCord, Velocity, or cloud dependencies. HTTP is the first transport +adapter. The immutable request/response envelope DTOs provide the correlation and version fields needed by future +transports. An optional cloud relay can later carry the same versioned API over an outbound authenticated WebSocket. +Control remains authoritative and local access continues without cloud connectivity. + +The server binds to `127.0.0.1` by default. **There is no authentication in this milestone; do not bind it to an +untrusted interface.** Registration is discovery data, not an authenticated identity proof. The API accepts unknown JSON +fields so additive fields from newer peers can be ignored, but requires protocol version `1` and validates all understood +fields. Bodies are limited to 16 KiB and node listings are paginated (maximum 100 items). + +## Build and run + +Requirements: JDK 17+ and Maven 3.9+. + +```shell +mvn clean verify +java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar +``` + +The service stores only its stable Control instance UUID under `./data`; node registrations are currently in memory. +Configuration is available through environment variables: + +| Variable | Default | Purpose | +| --- | --- | --- | +| `CONTROL_HOST` | `127.0.0.1` | Listen address | +| `CONTROL_PORT` | `8080` | Listen port (`0` selects a free port) | +| `CONTROL_DATA_DIR` | `data` | Local identity/data directory | + +Example health check: `curl http://127.0.0.1:8080/api/v1/health`. + +## API v1 + +All bodies and responses use JSON. Errors have the form +`{"error":{"code":"VALIDATION_ERROR","message":"...","details":["..."]}}`. + +| Method | Endpoint | Behavior | +| --- | --- | --- | +| `GET` | `/api/v1/health` | Health, application/protocol version, and Control identity | +| `POST` | `/api/v1/nodes/register` | Register a node, or replace discovery metadata for the same stable node ID | +| `PUT` | `/api/v1/nodes/{nodeId}/heartbeat` | Refresh last-seen and optionally negotiated capabilities | +| `GET` | `/api/v1/nodes?offset=0&limit=50` | List nodes with computed online state | + +Registration fields are `nodeId`, `displayName`, `platform` (`BUNGEECORD`, `VELOCITY`, `BUKKIT`, or `OTHER`), +`pluginVersion`, `protocolVersion`, and `capabilities`. Capability identifiers are bounded lowercase tokens such as +`status.read` or `servers.list`. A node is offline when it has not registered or sent a heartbeat for 90 seconds. + +## Planned security and management model + +Configuration management will start with a narrow typed domain—not unrestricted YAML—and use revision reads, +per-target validation, preview, compare-and-set application, backup, atomic writes, reload-after-persist, per-node results, +and local audit records. APIs will redact credentials, database passwords, encryption keys, webhook secrets, and device +credentials. + +Remote support will use expiring, revocable, permission-scoped owner invitations. Typed operations (status, redacted +configuration, diagnostics, proposing changes, and owner-approved application) will be audited locally. It will not expose +a shell, arbitrary files, raw databases, or unrestricted console commands. + +Future distribution will support explicit opt-in, pinned compatible versions, signed metadata and SHA-256 verification, +staging, atomic activation, rollback retention, health checks, manual installs, disabling updates, and an update lock while +configuration work is active. + +## Intentionally out of scope + +This milestone does not include a WebUI, persistence of node registrations, TLS/authenticated node enrollment, +VotingPlugin-side code, server-presence ingestion, configuration mutation, audit storage, release downloading, automatic +updates, cloud service, relay connection, or remote-support sessions. VotingPlugin integration details must be based on +its actual implementations before a connector is added; that source was not available in this development environment. diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..98751420 --- /dev/null +++ b/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + com.bencodez + votingplugin-control + 0.1.0-SNAPSHOT + + 17 + UTF-8 + 5.11.4 + 2.18.2 + + + + com.fasterxml.jackson.corejackson-databind${jackson.version} + + + com.fasterxml.jackson.datatypejackson-datatype-jsr310${jackson.version} + + + org.junit.jupiterjunit-jupiter${junit.version}test + + + + + + org.apache.maven.pluginsmaven-compiler-plugin3.13.0 + + + org.apache.maven.pluginsmaven-surefire-plugin3.5.2 + + + org.apache.maven.pluginsmaven-jar-plugin3.4.2 + com.bencodez.votingplugin.control.ControlApplication + + + org.apache.maven.pluginsmaven-shade-plugin3.6.0 + packageshadetrueall + + + + diff --git a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java new file mode 100644 index 00000000..6a59408d --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java @@ -0,0 +1,37 @@ +package com.bencodez.votingplugin.control; + +import com.bencodez.votingplugin.control.domain.InMemoryNodeRegistry; +import com.bencodez.votingplugin.control.http.ControlHttpServer; +import com.bencodez.votingplugin.control.protocol.*; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.file.*; +import java.time.*; +import java.util.UUID; + +public final class ControlApplication { + private static final String VERSION = "0.1.0-SNAPSHOT"; + private ControlApplication() { } + public static void main(String[] args) throws Exception { + String host = System.getenv().getOrDefault("CONTROL_HOST", "127.0.0.1"); + int port = Integer.parseInt(System.getenv().getOrDefault("CONTROL_PORT", "8080")); + Path dataDirectory = Path.of(System.getenv().getOrDefault("CONTROL_DATA_DIR", "data")); + ControlIdentity identity = new ControlIdentity(loadIdentity(dataDirectory), VERSION, Protocol.VERSION); + ControlHttpServer server = new ControlHttpServer(new InetSocketAddress(host, port), + new InMemoryNodeRegistry(Clock.systemUTC(), Duration.ofSeconds(90)), identity); + Runtime.getRuntime().addShutdownHook(new Thread(server::close)); + server.start(); + System.out.printf("VotingPlugin Control %s listening on http://%s:%d (protocol v%d)%n", VERSION, host, server.port(), Protocol.VERSION); + } + static UUID loadIdentity(Path directory) throws IOException { + Files.createDirectories(directory); + Path file = directory.resolve("instance-id"); + if (Files.exists(file)) return UUID.fromString(Files.readString(file).trim()); + UUID id = UUID.randomUUID(); + Path temporary = Files.createTempFile(directory, "instance-id-", ".tmp"); + Files.writeString(temporary, id.toString(), StandardOpenOption.TRUNCATE_EXISTING); + try { Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE); } + catch (FileAlreadyExistsException e) { Files.deleteIfExists(temporary); return UUID.fromString(Files.readString(file).trim()); } + return id; + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java new file mode 100644 index 00000000..a302737a --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -0,0 +1,75 @@ +package com.bencodez.votingplugin.control.domain; + +import com.bencodez.votingplugin.control.protocol.*; +import java.time.*; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Pattern; + +public final class InMemoryNodeRegistry implements NodeRegistry { + private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); + private static final Pattern CAPABILITY = Pattern.compile("[a-z][a-z0-9.-]{0,63}"); + private static final Set PLATFORMS = Set.of("BUNGEECORD", "VELOCITY", "BUKKIT", "OTHER"); + private final Map nodes = new ConcurrentHashMap<>(); + private final Clock clock; + private final Duration offlineTimeout; + + public InMemoryNodeRegistry(Clock clock, Duration offlineTimeout) { + this.clock = Objects.requireNonNull(clock); this.offlineTimeout = Objects.requireNonNull(offlineTimeout); + if (offlineTimeout.isNegative() || offlineTimeout.isZero()) throw new IllegalArgumentException("offlineTimeout must be positive"); + } + + @Override public RegistrationResult register(NodeRegistration r) { + validate(r); + Instant now = clock.instant(); + StoredNode stored = new StoredNode(r.nodeId(), r.displayName(), r.platform(), r.pluginVersion(), + r.protocolVersion(), r.capabilities(), now); + AtomicBoolean created = new AtomicBoolean(); + nodes.compute(r.nodeId(), (ignored, existing) -> { + created.set(existing == null); + return stored; + }); + return new RegistrationResult(view(stored), created.get()); + } + + @Override public NodeStatus heartbeat(String nodeId, Heartbeat heartbeat) { + validateNodeId(nodeId); + if (heartbeat == null) throw invalid("heartbeat is required"); + validateProtocol(heartbeat.protocolVersion()); + validateCapabilities(heartbeat.capabilities()); + StoredNode updated = nodes.computeIfPresent(nodeId, (ignored, old) -> new StoredNode(old.nodeId, + old.displayName, old.platform, old.pluginVersion, old.protocolVersion, + heartbeat.capabilities() == null ? old.capabilities : heartbeat.capabilities(), clock.instant())); + if (updated == null) throw new ValidationException("NODE_NOT_FOUND", "Node is not registered", List.of(nodeId)); + return view(updated); + } + + @Override public List list(int offset, int limit) { + if (offset < 0 || limit < 1 || limit > 100) throw invalid("offset must be >= 0 and limit must be between 1 and 100"); + return nodes.values().stream().sorted(Comparator.comparing(n -> n.nodeId)).skip(offset).limit(limit).map(this::view).toList(); + } + + private NodeStatus view(StoredNode n) { + boolean online = clock.instant().isBefore(n.lastSeen.plus(offlineTimeout)); + return new NodeStatus(n.nodeId, n.displayName, n.platform, n.pluginVersion, n.protocolVersion, n.capabilities, n.lastSeen, online); + } + private static void validate(NodeRegistration r) { + if (r == null) throw invalid("registration is required"); + validateNodeId(r.nodeId()); + requireText(r.displayName(), "displayName", 100); + requireText(r.pluginVersion(), "pluginVersion", 40); + if (r.platform() == null || !PLATFORMS.contains(r.platform())) throw invalid("platform must be one of " + PLATFORMS); + validateProtocol(r.protocolVersion()); validateCapabilities(r.capabilities()); + } + private static void validateNodeId(String id) { if (id == null || !NODE_ID.matcher(id).matches()) throw invalid("nodeId must match " + NODE_ID.pattern()); } + private static void validateProtocol(int version) { if (version != Protocol.VERSION) throw new ValidationException("UNSUPPORTED_PROTOCOL", "Unsupported protocol version", List.of("supported=" + Protocol.VERSION)); } + private static void validateCapabilities(Set capabilities) { + if (capabilities == null) return; + if (capabilities.size() > 64 || capabilities.stream().anyMatch(c -> c == null || !CAPABILITY.matcher(c).matches())) throw invalid("capabilities must contain at most 64 valid identifiers"); + } + private static void requireText(String value, String field, int max) { if (value == null || value.isBlank() || value.length() > max) throw invalid(field + " must contain 1 to " + max + " characters"); } + private static ValidationException invalid(String detail) { return new ValidationException("VALIDATION_ERROR", "Request validation failed", List.of(detail)); } + private record StoredNode(String nodeId, String displayName, String platform, String pluginVersion, + int protocolVersion, Set capabilities, Instant lastSeen) { StoredNode { capabilities = Set.copyOf(capabilities); } } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java new file mode 100644 index 00000000..1557b093 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java @@ -0,0 +1,13 @@ +package com.bencodez.votingplugin.control.domain; + +import com.bencodez.votingplugin.control.protocol.Heartbeat; +import com.bencodez.votingplugin.control.protocol.NodeRegistration; +import com.bencodez.votingplugin.control.protocol.NodeStatus; +import java.util.List; + +public interface NodeRegistry { + RegistrationResult register(NodeRegistration registration); + NodeStatus heartbeat(String nodeId, Heartbeat heartbeat); + List list(int offset, int limit); + record RegistrationResult(NodeStatus node, boolean created) { } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ValidationException.java b/src/main/java/com/bencodez/votingplugin/control/domain/ValidationException.java new file mode 100644 index 00000000..778805c9 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ValidationException.java @@ -0,0 +1,14 @@ +package com.bencodez.votingplugin.control.domain; + +import java.util.List; + +@SuppressWarnings("serial") +public final class ValidationException extends RuntimeException { + private final String code; + private final List details; + public ValidationException(String code, String message, List details) { + super(message); this.code = code; this.details = List.copyOf(details); + } + public String code() { return code; } + public List details() { return details; } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java new file mode 100644 index 00000000..e14b28b4 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -0,0 +1,124 @@ +package com.bencodez.votingplugin.control.http; + +import com.bencodez.votingplugin.control.domain.*; +import com.bencodez.votingplugin.control.protocol.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.databind.*; +import com.sun.net.httpserver.*; +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.*; + +public final class ControlHttpServer implements AutoCloseable { + public static final int MAX_REQUEST_BYTES = 16 * 1024; + private final HttpServer server; + private final ObjectMapper json; + private final NodeRegistry registry; + private final ControlIdentity identity; + private final ExecutorService executor; + + public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity) throws IOException { + this.registry = Objects.requireNonNull(registry); this.identity = Objects.requireNonNull(identity); + json = new ObjectMapper(); + json.findAndRegisterModules(); + json.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + json.getFactory().setStreamReadConstraints(StreamReadConstraints.builder().maxNestingDepth(20).maxStringLength(4096).build()); + server = HttpServer.create(address, 0); + server.createContext("/api/v1/health", this::health); + server.createContext("/api/v1/nodes", this::nodes); + executor = Executors.newCachedThreadPool(); + server.setExecutor(executor); + } + + public void start() { server.start(); } + public int port() { return server.getAddress().getPort(); } + @Override public void close() { server.stop(0); executor.shutdownNow(); } + + private void health(HttpExchange exchange) throws IOException { + if (!method(exchange, "GET")) return; + send(exchange, 200, Map.of("status", "ok", "time", Instant.now(), "identity", identity)); + } + + private void nodes(HttpExchange exchange) throws IOException { + try { + String path = exchange.getRequestURI().getPath(); + if (path.equals("/api/v1/nodes") && exchange.getRequestMethod().equals("GET")) { + Map query = query(exchange.getRequestURI().getRawQuery()); + int offset = integer(query.getOrDefault("offset", "0"), "offset"); + int limit = integer(query.getOrDefault("limit", "50"), "limit"); + List nodes = registry.list(offset, limit); + send(exchange, 200, Map.of("items", nodes, "offset", offset, "limit", limit)); + return; + } + if (path.equals("/api/v1/nodes/register")) { + if (!method(exchange, "POST")) return; + NodeRegistry.RegistrationResult result = registry.register(read(exchange, NodeRegistration.class)); + send(exchange, result.created() ? 201 : 200, Map.of("created", result.created(), "node", result.node())); + return; + } + String prefix = "/api/v1/nodes/"; + if (path.startsWith(prefix) && path.endsWith("/heartbeat")) { + if (!method(exchange, "PUT")) return; + String encodedId = path.substring(prefix.length(), path.length() - "/heartbeat".length()); + String nodeId = URLDecoder.decode(encodedId, StandardCharsets.UTF_8); + if (nodeId.contains("/")) throw invalid("nodeId path segment is invalid"); + send(exchange, 200, Map.of("node", registry.heartbeat(nodeId, read(exchange, Heartbeat.class)))); + return; + } + error(exchange, 404, "NOT_FOUND", "Endpoint not found", List.of()); + } catch (ValidationException e) { + error(exchange, e.code().equals("NODE_NOT_FOUND") ? 404 : e.code().equals("UNSUPPORTED_PROTOCOL") ? 409 + : e.code().equals("UNSUPPORTED_MEDIA_TYPE") ? 415 : 400, + e.code(), e.getMessage(), e.details()); + } catch (JsonProcessingException e) { + error(exchange, 400, "MALFORMED_JSON", "Request body is not valid JSON", List.of()); + } catch (RequestTooLargeException e) { + error(exchange, 413, "REQUEST_TOO_LARGE", "Request body exceeds " + MAX_REQUEST_BYTES + " bytes", List.of()); + } catch (NumberFormatException e) { + error(exchange, 400, "VALIDATION_ERROR", "Query parameter must be an integer", List.of(e.getMessage())); + } + } + + private T read(HttpExchange exchange, Class type) throws IOException { + String contentType = exchange.getRequestHeaders().getFirst("Content-Type"); + if (contentType == null || !contentType.toLowerCase(Locale.ROOT).startsWith("application/json")) + throw new ValidationException("UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json", List.of()); + String length = exchange.getRequestHeaders().getFirst("Content-Length"); + if (length != null && Long.parseLong(length) > MAX_REQUEST_BYTES) throw new RequestTooLargeException(); + byte[] bytes; + try (InputStream input = exchange.getRequestBody()) { bytes = input.readNBytes(MAX_REQUEST_BYTES + 1); } + if (bytes.length > MAX_REQUEST_BYTES) throw new RequestTooLargeException(); + return json.readValue(bytes, type); + } + private boolean method(HttpExchange exchange, String expected) throws IOException { + if (exchange.getRequestMethod().equals(expected)) return true; + exchange.getResponseHeaders().set("Allow", expected); + error(exchange, 405, "METHOD_NOT_ALLOWED", "Expected " + expected, List.of()); return false; + } + private void send(HttpExchange exchange, int status, Object value) throws IOException { + byte[] body = json.writeValueAsBytes(value); + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + exchange.getResponseHeaders().set("Cache-Control", "no-store"); + exchange.sendResponseHeaders(status, body.length); + try (OutputStream output = exchange.getResponseBody()) { output.write(body); } + } + private void error(HttpExchange exchange, int status, String code, String message, List details) throws IOException { + send(exchange, status, Map.of("error", new ProtocolError(code, message, details))); + } + private static int integer(String value, String name) { try { return Integer.parseInt(value); } catch (NumberFormatException e) { throw new NumberFormatException(name); } } + private static Map query(String raw) { + Map result = new HashMap<>(); + if (raw == null || raw.isBlank()) return result; + for (String item : raw.split("&")) { + String[] pair = item.split("=", 2); + result.put(URLDecoder.decode(pair[0], StandardCharsets.UTF_8), URLDecoder.decode(pair.length == 2 ? pair[1] : "", StandardCharsets.UTF_8)); + } + return result; + } + private static ValidationException invalid(String detail) { return new ValidationException("VALIDATION_ERROR", "Request validation failed", List.of(detail)); } + private static final class RequestTooLargeException extends IOException { } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ControlIdentity.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ControlIdentity.java new file mode 100644 index 00000000..e0ce5fd8 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ControlIdentity.java @@ -0,0 +1,5 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.util.UUID; + +public record ControlIdentity(UUID instanceId, String applicationVersion, int protocolVersion) { } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/Heartbeat.java b/src/main/java/com/bencodez/votingplugin/control/protocol/Heartbeat.java new file mode 100644 index 00000000..a3bf5880 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/Heartbeat.java @@ -0,0 +1,9 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.util.Set; + +public record Heartbeat(int protocolVersion, Set capabilities) { + public Heartbeat { + capabilities = capabilities == null ? null : Set.copyOf(capabilities); + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/NodeRegistration.java b/src/main/java/com/bencodez/votingplugin/control/protocol/NodeRegistration.java new file mode 100644 index 00000000..f4bdf8a8 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/NodeRegistration.java @@ -0,0 +1,10 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.util.Set; + +public record NodeRegistration(String nodeId, String displayName, String platform, + String pluginVersion, int protocolVersion, Set capabilities) { + public NodeRegistration { + capabilities = capabilities == null ? Set.of() : Set.copyOf(capabilities); + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/NodeStatus.java b/src/main/java/com/bencodez/votingplugin/control/protocol/NodeStatus.java new file mode 100644 index 00000000..a20f0eac --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/NodeStatus.java @@ -0,0 +1,11 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.time.Instant; +import java.util.Set; + +public record NodeStatus(String nodeId, String displayName, String platform, String pluginVersion, + int protocolVersion, Set capabilities, Instant lastSeen, boolean online) { + public NodeStatus { + capabilities = Set.copyOf(capabilities); + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/Protocol.java b/src/main/java/com/bencodez/votingplugin/control/protocol/Protocol.java new file mode 100644 index 00000000..63a87dfa --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/Protocol.java @@ -0,0 +1,6 @@ +package com.bencodez.votingplugin.control.protocol; + +public final class Protocol { + public static final int VERSION = 1; + private Protocol() { } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ProtocolError.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ProtocolError.java new file mode 100644 index 00000000..cb144ca4 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ProtocolError.java @@ -0,0 +1,9 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.util.List; + +public record ProtocolError(String code, String message, List details) { + public ProtocolError { + details = details == null ? List.of() : List.copyOf(details); + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/RequestEnvelope.java b/src/main/java/com/bencodez/votingplugin/control/protocol/RequestEnvelope.java new file mode 100644 index 00000000..0eaa570c --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/RequestEnvelope.java @@ -0,0 +1,3 @@ +package com.bencodez.votingplugin.control.protocol; + +public record RequestEnvelope(int protocolVersion, String correlationId, String operation, T payload) { } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ResponseEnvelope.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ResponseEnvelope.java new file mode 100644 index 00000000..b7ada9bf --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ResponseEnvelope.java @@ -0,0 +1,4 @@ +package com.bencodez.votingplugin.control.protocol; + +public record ResponseEnvelope(int protocolVersion, String correlationId, boolean success, T payload, + ProtocolError error) { } diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java new file mode 100644 index 00000000..2da2faf8 --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java @@ -0,0 +1,39 @@ +package com.bencodez.votingplugin.control.domain; + +import com.bencodez.votingplugin.control.protocol.*; +import org.junit.jupiter.api.Test; +import java.time.*; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.*; + +class InMemoryNodeRegistryTest { + @Test void heartbeatUpdatesCapabilitiesAndOfflineStateTracksTimeout() { + MutableClock clock = new MutableClock(Instant.parse("2026-08-24T00:00:00Z")); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofSeconds(90)); + registry.register(new NodeRegistration("proxy-a", "Proxy A", "VELOCITY", "6.20", 1, Set.of("status.read"))); + clock.advance(Duration.ofSeconds(89)); + assertTrue(registry.list(0, 10).get(0).online()); + NodeStatus heartbeat = registry.heartbeat("proxy-a", new Heartbeat(1, Set.of("status.read", "servers.list"))); + assertEquals(Set.of("status.read", "servers.list"), heartbeat.capabilities()); + clock.advance(Duration.ofSeconds(90)); + assertFalse(registry.list(0, 10).get(0).online()); + } + + @Test void rejectsInvalidIdentifiersAndUnknownNodes() { + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(Clock.systemUTC(), Duration.ofSeconds(90)); + ValidationException invalid = assertThrows(ValidationException.class, () -> registry.register( + new NodeRegistration("bad/id", "Proxy", "BUNGEECORD", "1", 1, Set.of()))); + assertEquals("VALIDATION_ERROR", invalid.code()); + assertEquals("NODE_NOT_FOUND", assertThrows(ValidationException.class, + () -> registry.heartbeat("missing", new Heartbeat(1, null))).code()); + } + + private static final class MutableClock extends Clock { + private Instant instant; + MutableClock(Instant instant) { this.instant = instant; } + void advance(Duration duration) { instant = instant.plus(duration); } + @Override public ZoneId getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(ZoneId zone) { return this; } + @Override public Instant instant() { return instant; } + } +} diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java new file mode 100644 index 00000000..83ddc892 --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -0,0 +1,89 @@ +package com.bencodez.votingplugin.control.http; + +import com.bencodez.votingplugin.control.domain.InMemoryNodeRegistry; +import com.bencodez.votingplugin.control.protocol.*; +import com.fasterxml.jackson.databind.*; +import org.junit.jupiter.api.*; +import java.net.*; +import java.net.http.*; +import java.time.*; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; + +class ControlHttpServerTest { + private ControlHttpServer server; + private HttpClient client; + private ObjectMapper json; + private URI base; + + @BeforeEach void start() throws Exception { + server = new ControlHttpServer(new InetSocketAddress("127.0.0.1", 0), + new InMemoryNodeRegistry(Clock.systemUTC(), Duration.ofSeconds(90)), + new ControlIdentity(UUID.fromString("00000000-0000-0000-0000-000000000001"), "test", 1)); + server.start(); + base = URI.create("http://127.0.0.1:" + server.port()); + client = HttpClient.newHttpClient(); + json = new ObjectMapper(); + } + @AfterEach void stop() { server.close(); } + + @Test void healthIsVersionedAndHealthy() throws Exception { + HttpResponse response = get("/api/v1/health"); + assertEquals(200, response.statusCode()); + JsonNode body = json.readTree(response.body()); + assertEquals("ok", body.get("status").asText()); + assertEquals(1, body.at("/identity/protocolVersion").asInt()); + } + + @Test void registrationIsCreatedThenUpdatedAndListed() throws Exception { + String first = "{\"nodeId\":\"proxy-a\",\"displayName\":\"Proxy A\",\"platform\":\"VELOCITY\",\"pluginVersion\":\"6.20\",\"protocolVersion\":1,\"capabilities\":[\"status.read\"]}"; + assertEquals(201, send("POST", "/api/v1/nodes/register", first).statusCode()); + String update = first.replace("Proxy A", "Main Proxy"); + HttpResponse updated = send("POST", "/api/v1/nodes/register", update); + assertEquals(200, updated.statusCode()); + assertFalse(json.readTree(updated.body()).get("created").asBoolean()); + JsonNode listed = json.readTree(get("/api/v1/nodes?limit=10").body()); + assertEquals(1, listed.get("items").size()); + assertEquals("Main Proxy", listed.at("/items/0/displayName").asText()); + } + + @Test void heartbeatUpdatesLastSeenAndCapabilities() throws Exception { + register("proxy-a", "[\"status.read\"]"); + HttpResponse response = send("PUT", "/api/v1/nodes/proxy-a/heartbeat", + "{\"protocolVersion\":1,\"capabilities\":[\"servers.list\"]}"); + assertEquals(200, response.statusCode()); + JsonNode node = json.readTree(response.body()).get("node"); + assertEquals("servers.list", node.get("capabilities").get(0).asText()); + assertNotNull(Instant.parse(node.get("lastSeen").asText())); + } + + @Test void malformedPayloadInvalidIdAndOversizedBodyReturnStructuredErrors() throws Exception { + assertError(send("POST", "/api/v1/nodes/register", "{"), 400, "MALFORMED_JSON"); + String invalid = "{\"nodeId\":\"bad/id\",\"displayName\":\"x\",\"platform\":\"OTHER\",\"pluginVersion\":\"1\",\"protocolVersion\":1}"; + assertError(send("POST", "/api/v1/nodes/register", invalid), 400, "VALIDATION_ERROR"); + assertError(send("POST", "/api/v1/nodes/register", " ".repeat(ControlHttpServer.MAX_REQUEST_BYTES + 1)), 413, "REQUEST_TOO_LARGE"); + } + + @Test void ignoresUnknownJsonFieldsForForwardCompatibility() throws Exception { + String payload = "{\"nodeId\":\"future-proxy\",\"displayName\":\"Future\",\"platform\":\"BUNGEECORD\",\"pluginVersion\":\"1\",\"protocolVersion\":1,\"futureField\":{\"value\":true}}"; + assertEquals(201, send("POST", "/api/v1/nodes/register", payload).statusCode()); + } + + @Test void rejectsInvalidCapabilitiesAndProtocolVersions() throws Exception { + assertError(register("proxy-a", "[\"INVALID CAPABILITY\"]"), 400, "VALIDATION_ERROR"); + String payload = "{\"nodeId\":\"proxy-a\",\"displayName\":\"A\",\"platform\":\"OTHER\",\"pluginVersion\":\"1\",\"protocolVersion\":2}"; + assertError(send("POST", "/api/v1/nodes/register", payload), 409, "UNSUPPORTED_PROTOCOL"); + } + + private HttpResponse register(String id, String capabilities) throws Exception { + return send("POST", "/api/v1/nodes/register", "{\"nodeId\":\"" + id + "\",\"displayName\":\"A\",\"platform\":\"OTHER\",\"pluginVersion\":\"1\",\"protocolVersion\":1,\"capabilities\":" + capabilities + "}"); + } + private HttpResponse get(String path) throws Exception { return client.send(HttpRequest.newBuilder(base.resolve(path)).GET().build(), HttpResponse.BodyHandlers.ofString()); } + private HttpResponse send(String method, String path, String body) throws Exception { + return client.send(HttpRequest.newBuilder(base.resolve(path)).header("Content-Type", "application/json") + .method(method, HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString()); + } + private void assertError(HttpResponse response, int status, String code) throws Exception { + assertEquals(status, response.statusCode()); assertEquals(code, json.readTree(response.body()).at("/error/code").asText()); + } +} From b4bf6ee9fa9c42ac99054605f7c1e7db2aaa7402 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 24 Aug 2026 18:40:53 -0600 Subject: [PATCH 02/42] Add authenticated read-only discovery --- .github/workflows/maven.yml | 22 + .gitignore | 1 + README.md | 144 ++++--- pom.xml | 10 +- .../control/ControlApplication.java | 162 ++++++- .../votingplugin/control/VersionInfo.java | 11 + .../control/auth/CredentialStore.java | 190 +++++++++ .../control/domain/InMemoryNodeRegistry.java | 281 +++++++++--- .../control/domain/NodeRegistry.java | 4 + .../control/http/ControlHttpServer.java | 402 ++++++++++++++---- .../protocol/BackendServerIdentity.java | 5 + .../control/protocol/Heartbeat.java | 8 +- .../control/protocol/NodeRegistration.java | 8 +- .../control/protocol/NodeStatus.java | 12 +- .../control/protocol/PresenceSnapshot.java | 12 + .../control/ControlApplicationTest.java | 63 +++ .../control/auth/CredentialStoreTest.java | 40 ++ .../domain/InMemoryNodeRegistryTest.java | 156 ++++++- .../control/http/ControlHttpServerTest.java | 189 +++++--- 19 files changed, 1442 insertions(+), 278 deletions(-) create mode 100644 .github/workflows/maven.yml create mode 100644 src/main/java/com/bencodez/votingplugin/control/VersionInfo.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/BackendServerIdentity.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/PresenceSnapshot.java create mode 100644 src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java create mode 100644 src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml new file mode 100644 index 00000000..931db6a8 --- /dev/null +++ b/.github/workflows/maven.yml @@ -0,0 +1,22 @@ +name: Java CI with Maven + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + - run: mvn -B clean verify diff --git a/.gitignore b/.gitignore index 16b61c5d..d29a25c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target/ /.idea/ *.iml +/data/ diff --git a/README.md b/README.md index 66890b7b..dcb90d88 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,122 @@ # VotingPlugin Control -VotingPlugin Control is the local-first administration service for a VotingPlugin network. This repository currently -contains the first runnable protocol and service foundation—not a VotingPlugin integration or a finished management UI. +VotingPlugin Control is a separate, local-first administration service for a VotingPlugin network. The current milestone +provides authenticated, read-only discovery of multiple BungeeCord and Velocity proxies and the backend servers each +proxy observes. It does not process votes, and VotingPlugin does not depend on it for startup, joins, routing, or shutdown. -## Architecture and boundaries +## Trust and deployment boundary -Control is one local application intended to observe and, in later milestones, coordinate multiple BungeeCord and -Velocity proxies and their backend servers. VotingPlugin nodes will connect to Control; Control is never in the vote -processing path. A stopped or unreachable Control instance must therefore have no effect on voting. +One Control process can observe an entire network: -The protocol/domain package has no HTTP, Bukkit, BungeeCord, Velocity, or cloud dependencies. HTTP is the first transport -adapter. The immutable request/response envelope DTOs provide the correlation and version fields needed by future -transports. An optional cloud relay can later carry the same versioned API over an outbound authenticated WebSocket. -Control remains authoritative and local access continues without cloud connectivity. +```text +Browser (future local UI) -> Control <- Proxy A / Proxy B / Proxy C <- backend servers +``` + +Proxy connectors initiate outbound HTTP(S) requests. Bukkit backend nodes do not connect directly. Control works without +Internet or a cloud account. Node calls require explicitly created, node-bound bearer credentials; management reads +require a separate admin credential. Raw credentials are never stored by Control: `data/credentials.json` contains only +SHA-256 verifiers for 256-bit random tokens. -The server binds to `127.0.0.1` by default. **There is no authentication in this milestone; do not bind it to an -untrusted interface.** Registration is discovery data, not an authenticated identity proof. The API accepts unknown JSON -fields so additive fields from newer peers can be ignored, but requires protocol version `1` and validates all understood -fields. Bodies are limited to 16 KiB and node listings are paginated (maximum 100 items). +Control binds to `127.0.0.1` by default. A non-loopback bind is refused until an admin credential exists. Authentication +does not encrypt traffic: use HTTPS or a trusted private tunnel/network when traffic can cross an untrusted network. Do +not publish the HTTP service directly to the Internet. -## Build and run +## Requirements, build, and run -Requirements: JDK 17+ and Maven 3.9+. +- Java 17 or newer (the produced JAR targets Java 17) +- Maven 3.9 or newer ```shell mvn clean verify java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar ``` -The service stores only its stable Control instance UUID under `./data`; node registrations are currently in memory. -Configuration is available through environment variables: +The application version comes from the JAR manifest generated from the Maven project version. Development classpath runs +report `development` rather than maintaining a second version literal. -| Variable | Default | Purpose | +| Variable | Default | Constraint | | --- | --- | --- | -| `CONTROL_HOST` | `127.0.0.1` | Listen address | -| `CONTROL_PORT` | `8080` | Listen port (`0` selects a free port) | -| `CONTROL_DATA_DIR` | `data` | Local identity/data directory | +| `CONTROL_HOST` | `127.0.0.1` | Valid bind host; non-loopback requires an admin credential | +| `CONTROL_PORT` | `8080` | `0`–`65535`; `0` selects a free port | +| `CONTROL_DATA_DIR` | `data` | Writable directory for identity and credential verifiers | +| `CONTROL_OFFLINE_TIMEOUT_SECONDS` | `90` | `1`–`3600` | +| `CONTROL_REQUEST_TIMEOUT_SECONDS` | `10` | `1`–`60`; bounds stalled JDK HTTP exchanges | -Example health check: `curl http://127.0.0.1:8080/api/v1/health`. +The server also uses a bounded HTTP executor (8 active requests and a 32-request queue), a 64 KiB request limit, bounded +JSON depth/string/number sizes, and a global authentication-failure limit. Shutdown stops the server and its daemon request +workers without waiting indefinitely. -## API v1 +## Enrollment -All bodies and responses use JSON. Errors have the form -`{"error":{"code":"VALIDATION_ERROR","message":"...","details":["..."]}}`. +Owner commands modify the configured data directory and print a new secret once. Stop shell history capture or otherwise +handle the output as a password. -| Method | Endpoint | Behavior | -| --- | --- | --- | -| `GET` | `/api/v1/health` | Health, application/protocol version, and Control identity | -| `POST` | `/api/v1/nodes/register` | Register a node, or replace discovery metadata for the same stable node ID | -| `PUT` | `/api/v1/nodes/{nodeId}/heartbeat` | Refresh last-seen and optionally negotiated capabilities | -| `GET` | `/api/v1/nodes?offset=0&limit=50` | List nodes with computed online state | +```shell +# Create or rotate a credential bound to proxy-a +java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar enroll proxy-a data + +# Immediately revoke it +java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar revoke proxy-a data + +# Create or rotate the credential used for management GET endpoints +java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar admin-token data +``` + +Rotating replaces the prior verifier immediately. The store is reread for every authentication decision, so a running +Control process observes rotation and revocation without restart. A credential for one node cannot register or update a +different node identity. Credentials belong in the `Authorization: Bearer ...` header, never URLs, logs, health output, +node listings, diagnostics, or plugin messages. + +## Protocol and API + +Protocol version `1` is exact for this milestone. Unsupported versions and unavailable required capabilities return a +structured `409`. Unknown advertised capabilities are retained but not negotiated; accepted capabilities are the +intersection with Control's supported capabilities. A heartbeat replaces the advertised capability set, so removal is +explicit. Unknown JSON fields are accepted for additive forward compatibility, while duplicate fields and trailing JSON +are rejected. + +All errors have the stable form: + +```json +{"error":{"code":"VALIDATION_ERROR","message":"Request validation failed","details":[]}} +``` + +| Method | Endpoint | Authentication | Behavior | +| --- | --- | --- | --- | +| `GET` | `/api/v1/health` | none | Health plus Control identity/application/protocol version | +| `POST` | `/api/v1/nodes/register` | node | Idempotently create/replace one enrolled proxy session | +| `PUT` | `/api/v1/nodes/{nodeId}/heartbeat` | matching node | Refresh liveness and replace capability advertisement | +| `PUT` | `/api/v1/nodes/{nodeId}/presence` | matching node | Replace that session's backend snapshot | +| `GET` | `/api/v1/nodes?offset=0&limit=50` | admin | Stable node-ID ordering; limit `1`–`100` | + +Routes are exact. Child suffixes do not inherit a handler, every known endpoint has an intentional method/structured 405, +and all unknown endpoints return a structured 404. -Registration fields are `nodeId`, `displayName`, `platform` (`BUNGEECORD`, `VELOCITY`, `BUKKIT`, or `OTHER`), -`pluginVersion`, `protocolVersion`, and `capabilities`. Capability identifiers are bounded lowercase tokens such as -`status.read` or `servers.list`. A node is offline when it has not registered or sent a heartbeat for 90 seconds. +Registration includes a stable node ID and a random process session ID. A new session replaces the old registration and +clears its old topology. Presence snapshots are full replacements, contain at most 4096 unique backend IDs, and use a +monotonic sequence within the registered session. Replayed or out-of-order sequence numbers are idempotently ignored. +Control records its own observation time; it does not trust remote wall-clock time for online/offline decisions. -## Planned security and management model +A node is online only while `lastSeen + offlineTimeout` is strictly after Control's current time. The exact timeout boundary +is offline. Backend entries distinguish whether authoritative backend presence is known from the existing VotingPlugin +presence protocol; server addresses and all unrelated configuration are excluded. -Configuration management will start with a narrow typed domain—not unrestricted YAML—and use revision reads, -per-target validation, preview, compare-and-set application, backup, atomic writes, reload-after-persist, per-node results, -and local audit records. APIs will redact credentials, database passwords, encryption keys, webhook secrets, and device -credentials. +The immutable correlated request/response envelope DTOs are reserved for later typed node operations and relay transports. +They are not misleadingly used by these simple HTTP resource endpoints. -Remote support will use expiring, revocable, permission-scoped owner invitations. Typed operations (status, redacted -configuration, diagnostics, proposing changes, and owner-approved application) will be audited locally. It will not expose -a shell, arbitrary files, raw databases, or unrestricted console commands. +## Troubleshooting -Future distribution will support explicit opt-in, pinned compatible versions, signed metadata and SHA-256 verification, -staging, atomic activation, rollback retention, health checks, manual installs, disabling updates, and an update lock while -configuration work is active. +- `401 UNAUTHORIZED`: the credential is missing, invalid, revoked, rotated, or enrolled for another node ID. +- `409 SESSION_MISMATCH`: re-register; Control has a newer process session for that stable node. +- `409 UNSUPPORTED_PROTOCOL` / `INCOMPATIBLE_CAPABILITIES`: upgrade the older side before retrying. +- Node stays offline: verify the proxy connector is enabled, the node ID matches enrollment, and heartbeat timeouts permit + the configured interval. +- LAN startup is refused: create the admin token first and protect transport with HTTPS/private tunneling. +- Corrupt `instance-id` or credential data fails closed; restore a known backup or deliberately recreate the affected file + and re-enroll nodes. ## Intentionally out of scope -This milestone does not include a WebUI, persistence of node registrations, TLS/authenticated node enrollment, -VotingPlugin-side code, server-presence ingestion, configuration mutation, audit storage, release downloading, automatic -updates, cloud service, relay connection, or remote-support sessions. VotingPlugin integration details must be based on -its actual implementations before a connector is added; that source was not available in this development environment. +This milestone has no configuration reads or writes, WebUI, topology persistence/history, audit log, diagnostics bundle, +automatic distribution, cloud relay, or remote-support sessions. Node registry state is currently in memory, so proxies +automatically re-register after a Control restart. Manual installation remains supported. diff --git a/pom.xml b/pom.xml index 98751420..d2b6c811 100644 --- a/pom.xml +++ b/pom.xml @@ -7,6 +7,7 @@ 0.1.0-SNAPSHOT 17 + 3.9.0 UTF-8 5.11.4 2.18.2 @@ -24,6 +25,13 @@ + + org.apache.maven.pluginsmaven-enforcer-plugin3.5.0 + enforce + [17,) + [${maven.version},) + + org.apache.maven.pluginsmaven-compiler-plugin3.13.0 @@ -32,7 +40,7 @@ org.apache.maven.pluginsmaven-jar-plugin3.4.2 - com.bencodez.votingplugin.control.ControlApplication + com.bencodez.votingplugin.control.ControlApplicationtrue org.apache.maven.pluginsmaven-shade-plugin3.6.0 diff --git a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java index 6a59408d..89bc239e 100644 --- a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java +++ b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java @@ -1,37 +1,159 @@ package com.bencodez.votingplugin.control; +import com.bencodez.votingplugin.control.auth.CredentialStore; import com.bencodez.votingplugin.control.domain.InMemoryNodeRegistry; import com.bencodez.votingplugin.control.http.ControlHttpServer; -import com.bencodez.votingplugin.control.protocol.*; +import com.bencodez.votingplugin.control.protocol.ControlIdentity; +import com.bencodez.votingplugin.control.protocol.Protocol; import java.io.IOException; +import java.net.InetAddress; import java.net.InetSocketAddress; -import java.nio.file.*; -import java.time.*; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Clock; +import java.time.Duration; +import java.util.Map; import java.util.UUID; +import java.util.concurrent.CountDownLatch; public final class ControlApplication { - private static final String VERSION = "0.1.0-SNAPSHOT"; private ControlApplication() { } - public static void main(String[] args) throws Exception { - String host = System.getenv().getOrDefault("CONTROL_HOST", "127.0.0.1"); - int port = Integer.parseInt(System.getenv().getOrDefault("CONTROL_PORT", "8080")); - Path dataDirectory = Path.of(System.getenv().getOrDefault("CONTROL_DATA_DIR", "data")); - ControlIdentity identity = new ControlIdentity(loadIdentity(dataDirectory), VERSION, Protocol.VERSION); - ControlHttpServer server = new ControlHttpServer(new InetSocketAddress(host, port), - new InMemoryNodeRegistry(Clock.systemUTC(), Duration.ofSeconds(90)), identity); - Runtime.getRuntime().addShutdownHook(new Thread(server::close)); + + public static void main(String[] args) { + try { + if (args.length > 0) { + runOwnerCommand(args); + } else { + runServer(System.getenv()); + } + } catch (IllegalArgumentException | IOException e) { + System.err.println("VotingPlugin Control could not complete the requested operation. " + + "Verify the arguments, configuration, and data-directory permissions."); + System.exit(1); + } catch (Exception e) { + System.err.println("VotingPlugin Control stopped because of an internal startup failure."); + System.exit(1); + } + } + + private static void runOwnerCommand(String[] args) throws IOException { + Path dataDirectory = args.length >= 3 ? requirePath(args[2]) + : requirePath(System.getenv().getOrDefault("CONTROL_DATA_DIR", "data")); + CredentialStore credentials = new CredentialStore(dataDirectory); + switch (args[0]) { + case "enroll" -> { + if (args.length < 2 || args.length > 3) { + throw new IllegalArgumentException("Usage: enroll [dataDirectory]"); + } + System.out.println(credentials.rotateNode(args[1])); + } + case "revoke" -> { + if (args.length < 2 || args.length > 3) { + throw new IllegalArgumentException("Usage: revoke [dataDirectory]"); + } + credentials.revokeNode(args[1]); + System.out.println("Enrollment revoked."); + } + case "admin-token" -> { + if (args.length > 2) { + throw new IllegalArgumentException("Usage: admin-token [dataDirectory]"); + } + Path selected = args.length == 2 ? requirePath(args[1]) : dataDirectory; + System.out.println(new CredentialStore(selected).rotateAdmin()); + } + default -> throw new IllegalArgumentException("Unknown command"); + } + } + + static void runServer(Map environment) throws Exception { + Configuration configuration = Configuration.from(environment); + CredentialStore credentials = new CredentialStore(configuration.dataDirectory()); + if (!configuration.address().getAddress().isLoopbackAddress() && !credentials.hasAdmin()) { + throw new IllegalArgumentException("An admin credential is required for non-loopback binding"); + } + // JDK HttpServer honors these bounds for stalled request and response bodies. + System.setProperty("sun.net.httpserver.maxReqTime", Integer.toString(configuration.requestTimeoutSeconds())); + System.setProperty("sun.net.httpserver.maxRspTime", Integer.toString(configuration.requestTimeoutSeconds())); + ControlIdentity identity = new ControlIdentity(loadIdentity(configuration.dataDirectory()), + VersionInfo.applicationVersion(), Protocol.VERSION); + ControlHttpServer server = new ControlHttpServer(configuration.address(), + new InMemoryNodeRegistry(Clock.systemUTC(), configuration.offlineTimeout()), identity, credentials); + CountDownLatch shutdown = new CountDownLatch(1); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + server.close(); + shutdown.countDown(); + }, "votingplugin-control-shutdown")); server.start(); - System.out.printf("VotingPlugin Control %s listening on http://%s:%d (protocol v%d)%n", VERSION, host, server.port(), Protocol.VERSION); + System.out.printf("VotingPlugin Control %s listening on http://%s:%d (protocol v%d)%n", + VersionInfo.applicationVersion(), configuration.address().getHostString(), server.port(), + Protocol.VERSION); + try { + shutdown.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + server.close(); + } } + + /** Create-if-absent publication cannot replace a concurrent startup's winning identity. */ static UUID loadIdentity(Path directory) throws IOException { Files.createDirectories(directory); Path file = directory.resolve("instance-id"); - if (Files.exists(file)) return UUID.fromString(Files.readString(file).trim()); - UUID id = UUID.randomUUID(); - Path temporary = Files.createTempFile(directory, "instance-id-", ".tmp"); - Files.writeString(temporary, id.toString(), StandardOpenOption.TRUNCATE_EXISTING); - try { Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE); } - catch (FileAlreadyExistsException e) { Files.deleteIfExists(temporary); return UUID.fromString(Files.readString(file).trim()); } - return id; + UUID candidate = UUID.randomUUID(); + try { + Files.writeString(file, candidate.toString(), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + return candidate; + } catch (FileAlreadyExistsException e) { + return readIdentity(file); + } + } + + private static UUID readIdentity(Path file) throws IOException { + try { + return UUID.fromString(Files.readString(file).trim()); + } catch (IllegalArgumentException e) { + throw new IOException("Control identity is invalid"); + } + } + + private static Path requirePath(String value) { + if (value == null || value.isBlank() || value.indexOf('\0') >= 0) { + throw new IllegalArgumentException("Invalid data path"); + } + return Path.of(value); + } + + record Configuration(InetSocketAddress address, Path dataDirectory, Duration offlineTimeout, + int requestTimeoutSeconds) { + static Configuration from(Map environment) throws IOException { + String host = environment.getOrDefault("CONTROL_HOST", "127.0.0.1").trim(); + if (host.isEmpty()) { + throw new IllegalArgumentException("Invalid host"); + } + InetAddress resolved = InetAddress.getByName(host); + int port = boundedInteger(environment.getOrDefault("CONTROL_PORT", "8080"), "port", 0, 65535); + int offlineSeconds = boundedInteger(environment.getOrDefault("CONTROL_OFFLINE_TIMEOUT_SECONDS", "90"), + "offline timeout", 1, 3600); + int requestSeconds = boundedInteger(environment.getOrDefault("CONTROL_REQUEST_TIMEOUT_SECONDS", "10"), + "request timeout", 1, 60); + return new Configuration(new InetSocketAddress(resolved, port), + requirePath(environment.getOrDefault("CONTROL_DATA_DIR", "data")), + Duration.ofSeconds(offlineSeconds), requestSeconds); + } + + private static int boundedInteger(String value, String name, int minimum, int maximum) { + int parsed; + try { + parsed = Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid " + name); + } + if (parsed < minimum || parsed > maximum) { + throw new IllegalArgumentException("Invalid " + name); + } + return parsed; + } } } diff --git a/src/main/java/com/bencodez/votingplugin/control/VersionInfo.java b/src/main/java/com/bencodez/votingplugin/control/VersionInfo.java new file mode 100644 index 00000000..c4768430 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/VersionInfo.java @@ -0,0 +1,11 @@ +package com.bencodez.votingplugin.control; + +/** Authoritative runtime application version sourced from the Maven-generated manifest. */ +public final class VersionInfo { + private VersionInfo() { } + + public static String applicationVersion() { + String version = VersionInfo.class.getPackage().getImplementationVersion(); + return version == null || version.isBlank() ? "development" : version; + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java b/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java new file mode 100644 index 00000000..91f06d1a --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java @@ -0,0 +1,190 @@ +package com.bencodez.votingplugin.control.auth; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; + +/** Persistent SHA-256 verifiers for high-entropy enrollment and local admin credentials. */ +public final class CredentialStore { + private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); + private static final int MAX_ENROLLMENTS = 10_000; + private static final byte[] DUMMY_HASH = new byte[32]; + + private final Path directory; + private final Path file; + private final Path lockFile; + private final ObjectMapper json = new ObjectMapper() + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + private final SecureRandom random; + + public CredentialStore(Path directory) throws IOException { + this(directory, new SecureRandom()); + } + + CredentialStore(Path directory, SecureRandom random) throws IOException { + this.directory = Objects.requireNonNull(directory, "directory"); + this.file = directory.resolve("credentials.json"); + this.lockFile = directory.resolve("credentials.lock"); + this.random = Objects.requireNonNull(random, "random"); + Files.createDirectories(directory); + } + + /** Rotates or creates a node credential and returns the secret exactly once. */ + public String rotateNode(String nodeId) throws IOException { + validateNodeId(nodeId); + String token = token("vpctl_node_"); + mutate(data -> { + if (!data.nodeHashes.containsKey(nodeId) && data.nodeHashes.size() >= MAX_ENROLLMENTS) { + throw new IllegalStateException("Maximum enrollment count reached"); + } + data.nodeHashes.put(nodeId, hashHex(token)); + }); + return token; + } + + public void revokeNode(String nodeId) throws IOException { + validateNodeId(nodeId); + mutate(data -> data.nodeHashes.remove(nodeId)); + } + + /** Rotates or creates the local management API credential and returns it exactly once. */ + public String rotateAdmin() throws IOException { + String token = token("vpctl_admin_"); + mutate(data -> data.adminHash = hashHex(token)); + return token; + } + + public boolean hasAdmin() throws IOException { + String hash = read().adminHash; + return hash != null && !hash.isBlank(); + } + + public boolean verifyNode(String nodeId, String token) { + String expected = null; + try { + if (nodeId != null && NODE_ID.matcher(nodeId).matches()) { + expected = read().nodeHashes.get(nodeId); + } + } catch (IOException ignored) { + // Authentication remains closed if the verifier file cannot be read. + } + return constantTimeMatches(expected, token); + } + + public boolean verifyAdmin(String token) { + String expected = null; + try { + expected = read().adminHash; + } catch (IOException ignored) { + // Authentication remains closed if the verifier file cannot be read. + } + return constantTimeMatches(expected, token); + } + + private boolean constantTimeMatches(String expectedHex, String token) { + byte[] expected = DUMMY_HASH; + if (expectedHex != null) { + try { + expected = HexFormat.of().parseHex(expectedHex); + } catch (IllegalArgumentException ignored) { + expected = DUMMY_HASH; + } + } + byte[] actual = token == null ? DUMMY_HASH : hash(token); + boolean equal = MessageDigest.isEqual(expected, actual); + return expectedHex != null && token != null && equal; + } + + private synchronized StoreData read() throws IOException { + if (!Files.exists(file)) { + return new StoreData(); + } + byte[] bytes = Files.readAllBytes(file); + if (bytes.length > 2 * 1024 * 1024) { + throw new IOException("Credential store is too large"); + } + try { + StoreData data = json.readValue(bytes, StoreData.class); + if (data == null) { + throw new IOException("Credential store is invalid"); + } + if (data.nodeHashes == null) { + data.nodeHashes = new HashMap<>(); + } + return data; + } catch (RuntimeException e) { + throw new IOException("Credential store is invalid"); + } + } + + private synchronized void mutate(Mutation mutation) throws IOException { + try (FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE); + FileLock ignored = channel.lock()) { + StoreData data = read(); + mutation.apply(data); + byte[] bytes = json.writerWithDefaultPrettyPrinter().writeValueAsBytes(data); + Path temporary = Files.createTempFile(directory, "credentials-", ".tmp"); + try { + Files.write(temporary, bytes, StandardOpenOption.TRUNCATE_EXISTING); + try { + Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporary); + } + } + } + + private String token(String prefix) { + byte[] bytes = new byte[32]; + random.nextBytes(bytes); + return prefix + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private static String hashHex(String token) { + return HexFormat.of().formatHex(hash(token)); + } + + private static byte[] hash(String token) { + try { + return MessageDigest.getInstance("SHA-256").digest(token.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable"); + } + } + + private static void validateNodeId(String nodeId) { + if (nodeId == null || !NODE_ID.matcher(nodeId).matches()) { + throw new IllegalArgumentException("nodeId must match " + NODE_ID.pattern()); + } + } + + @FunctionalInterface + private interface Mutation { + void apply(StoreData data); + } + + private static final class StoreData { + public String adminHash; + public Map nodeHashes = new HashMap<>(); + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java index a302737a..92407143 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -1,75 +1,252 @@ package com.bencodez.votingplugin.control.domain; -import com.bencodez.votingplugin.control.protocol.*; -import java.time.*; -import java.util.*; +import com.bencodez.votingplugin.control.protocol.BackendServerIdentity; +import com.bencodez.votingplugin.control.protocol.Heartbeat; +import com.bencodez.votingplugin.control.protocol.NodeRegistration; +import com.bencodez.votingplugin.control.protocol.NodeStatus; +import com.bencodez.votingplugin.control.protocol.PresenceSnapshot; +import com.bencodez.votingplugin.control.protocol.Protocol; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Pattern; +/** Thread-safe, deterministic current topology registry. State is intentionally in memory for this milestone. */ public final class InMemoryNodeRegistry implements NodeRegistry { - private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); + public static final Set SUPPORTED_CAPABILITIES = Set.of("discovery.read", "presence.snapshot"); + private static final Pattern ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final Pattern CAPABILITY = Pattern.compile("[a-z][a-z0-9.-]{0,63}"); - private static final Set PLATFORMS = Set.of("BUNGEECORD", "VELOCITY", "BUKKIT", "OTHER"); + private static final Set PLATFORMS = Set.of("BUNGEECORD", "VELOCITY"); + private static final int MAX_CAPABILITIES = 64; + private static final int MAX_BACKENDS = 4096; + private final Map nodes = new ConcurrentHashMap<>(); private final Clock clock; private final Duration offlineTimeout; public InMemoryNodeRegistry(Clock clock, Duration offlineTimeout) { - this.clock = Objects.requireNonNull(clock); this.offlineTimeout = Objects.requireNonNull(offlineTimeout); - if (offlineTimeout.isNegative() || offlineTimeout.isZero()) throw new IllegalArgumentException("offlineTimeout must be positive"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.offlineTimeout = Objects.requireNonNull(offlineTimeout, "offlineTimeout"); + if (offlineTimeout.isNegative() || offlineTimeout.isZero()) { + throw new IllegalArgumentException("offlineTimeout must be positive"); + } } - @Override public RegistrationResult register(NodeRegistration r) { - validate(r); + @Override + public RegistrationResult register(NodeRegistration registration) { + validate(registration); Instant now = clock.instant(); - StoredNode stored = new StoredNode(r.nodeId(), r.displayName(), r.platform(), r.pluginVersion(), - r.protocolVersion(), r.capabilities(), now); AtomicBoolean created = new AtomicBoolean(); - nodes.compute(r.nodeId(), (ignored, existing) -> { + AtomicReference result = new AtomicReference<>(); + nodes.compute(registration.nodeId(), (ignored, existing) -> { created.set(existing == null); - return stored; + List backends = existing != null + && existing.sessionId.equals(registration.sessionId()) ? existing.backends : List.of(); + long sequence = existing != null && existing.sessionId.equals(registration.sessionId()) + ? existing.snapshotSequence : -1L; + StoredNode replacement = new StoredNode(registration.nodeId(), registration.sessionId(), + registration.displayName().trim(), registration.platform(), registration.pluginVersion().trim(), + registration.protocolVersion(), registration.capabilities(), accepted(registration.capabilities()), + backends, sequence, now, now); + result.set(replacement); + return replacement; }); - return new RegistrationResult(view(stored), created.get()); + return new RegistrationResult(view(result.get()), created.get()); } - @Override public NodeStatus heartbeat(String nodeId, Heartbeat heartbeat) { - validateNodeId(nodeId); - if (heartbeat == null) throw invalid("heartbeat is required"); + @Override + public NodeStatus heartbeat(String nodeId, Heartbeat heartbeat) { + validateId(nodeId, "nodeId"); + if (heartbeat == null) { + throw invalid("heartbeat is required"); + } + validateSession(heartbeat.sessionId()); validateProtocol(heartbeat.protocolVersion()); - validateCapabilities(heartbeat.capabilities()); - StoredNode updated = nodes.computeIfPresent(nodeId, (ignored, old) -> new StoredNode(old.nodeId, - old.displayName, old.platform, old.pluginVersion, old.protocolVersion, - heartbeat.capabilities() == null ? old.capabilities : heartbeat.capabilities(), clock.instant())); - if (updated == null) throw new ValidationException("NODE_NOT_FOUND", "Node is not registered", List.of(nodeId)); - return view(updated); - } - - @Override public List list(int offset, int limit) { - if (offset < 0 || limit < 1 || limit > 100) throw invalid("offset must be >= 0 and limit must be between 1 and 100"); - return nodes.values().stream().sorted(Comparator.comparing(n -> n.nodeId)).skip(offset).limit(limit).map(this::view).toList(); - } - - private NodeStatus view(StoredNode n) { - boolean online = clock.instant().isBefore(n.lastSeen.plus(offlineTimeout)); - return new NodeStatus(n.nodeId, n.displayName, n.platform, n.pluginVersion, n.protocolVersion, n.capabilities, n.lastSeen, online); - } - private static void validate(NodeRegistration r) { - if (r == null) throw invalid("registration is required"); - validateNodeId(r.nodeId()); - requireText(r.displayName(), "displayName", 100); - requireText(r.pluginVersion(), "pluginVersion", 40); - if (r.platform() == null || !PLATFORMS.contains(r.platform())) throw invalid("platform must be one of " + PLATFORMS); - validateProtocol(r.protocolVersion()); validateCapabilities(r.capabilities()); - } - private static void validateNodeId(String id) { if (id == null || !NODE_ID.matcher(id).matches()) throw invalid("nodeId must match " + NODE_ID.pattern()); } - private static void validateProtocol(int version) { if (version != Protocol.VERSION) throw new ValidationException("UNSUPPORTED_PROTOCOL", "Unsupported protocol version", List.of("supported=" + Protocol.VERSION)); } - private static void validateCapabilities(Set capabilities) { - if (capabilities == null) return; - if (capabilities.size() > 64 || capabilities.stream().anyMatch(c -> c == null || !CAPABILITY.matcher(c).matches())) throw invalid("capabilities must contain at most 64 valid identifiers"); - } - private static void requireText(String value, String field, int max) { if (value == null || value.isBlank() || value.length() > max) throw invalid(field + " must contain 1 to " + max + " characters"); } - private static ValidationException invalid(String detail) { return new ValidationException("VALIDATION_ERROR", "Request validation failed", List.of(detail)); } - private record StoredNode(String nodeId, String displayName, String platform, String pluginVersion, - int protocolVersion, Set capabilities, Instant lastSeen) { StoredNode { capabilities = Set.copyOf(capabilities); } } + validateCapabilities(heartbeat.capabilities(), heartbeat.requiredCapabilities()); + Instant now = clock.instant(); + AtomicReference result = new AtomicReference<>(); + nodes.computeIfPresent(nodeId, (ignored, existing) -> { + requireSession(existing, heartbeat.sessionId()); + StoredNode updated = new StoredNode(existing.nodeId, existing.sessionId, existing.displayName, + existing.platform, existing.pluginVersion, existing.protocolVersion, heartbeat.capabilities(), + accepted(heartbeat.capabilities()), existing.backends, existing.snapshotSequence, now, now); + result.set(updated); + return updated; + }); + if (result.get() == null) { + throw new ValidationException("NODE_NOT_FOUND", "Node is not registered", List.of()); + } + return view(result.get()); + } + + @Override + public SnapshotResult replacePresence(String nodeId, PresenceSnapshot snapshot) { + validateId(nodeId, "nodeId"); + validateSnapshot(snapshot); + Instant now = clock.instant(); + AtomicBoolean applied = new AtomicBoolean(); + AtomicReference result = new AtomicReference<>(); + nodes.computeIfPresent(nodeId, (ignored, existing) -> { + requireSession(existing, snapshot.sessionId()); + if (snapshot.sequence() <= existing.snapshotSequence) { + result.set(existing); + return existing; + } + List sorted = snapshot.backends().stream() + .sorted(Comparator.comparing(BackendServerIdentity::backendId)).toList(); + StoredNode updated = new StoredNode(existing.nodeId, existing.sessionId, existing.displayName, + existing.platform, existing.pluginVersion, existing.protocolVersion, + existing.advertisedCapabilities, existing.acceptedCapabilities, sorted, snapshot.sequence(), + now, now); + applied.set(true); + result.set(updated); + return updated; + }); + if (result.get() == null) { + throw new ValidationException("NODE_NOT_FOUND", "Node is not registered", List.of()); + } + return new SnapshotResult(view(result.get()), applied.get()); + } + + @Override + public List list(int offset, int limit) { + if (offset < 0 || limit < 1 || limit > 100) { + throw invalid("offset must be >= 0 and limit must be between 1 and 100"); + } + return nodes.values().stream().sorted(Comparator.comparing(node -> node.nodeId)) + .skip(offset).limit(limit).map(this::view).toList(); + } + + private NodeStatus view(StoredNode node) { + // The exact boundary is offline: lastSeen + timeout must be strictly after now. + boolean online = clock.instant().isBefore(node.lastSeen.plus(offlineTimeout)); + return new NodeStatus(node.nodeId, node.sessionId, node.displayName, node.platform, node.pluginVersion, + node.protocolVersion, node.advertisedCapabilities, node.acceptedCapabilities, node.backends, + node.snapshotSequence, node.lastSeen, node.lastAuthenticatedUpdate, online); + } + + private static void validate(NodeRegistration registration) { + if (registration == null) { + throw invalid("registration is required"); + } + validateId(registration.nodeId(), "nodeId"); + validateSession(registration.sessionId()); + requireText(registration.displayName(), "displayName", 100); + requireText(registration.pluginVersion(), "pluginVersion", 40); + if (registration.platform() == null || !PLATFORMS.contains(registration.platform())) { + throw invalid("platform must be BUNGEECORD or VELOCITY"); + } + validateProtocol(registration.protocolVersion()); + validateCapabilities(registration.capabilities(), registration.requiredCapabilities()); + } + + private static void validateSnapshot(PresenceSnapshot snapshot) { + if (snapshot == null) { + throw invalid("presence snapshot is required"); + } + validateSession(snapshot.sessionId()); + validateProtocol(snapshot.protocolVersion()); + if (snapshot.sequence() < 0) { + throw invalid("sequence must be non-negative"); + } + if (snapshot.backends().size() > MAX_BACKENDS) { + throw invalid("backends must contain at most " + MAX_BACKENDS + " entries"); + } + Set ids = new HashSet<>(); + for (BackendServerIdentity backend : snapshot.backends()) { + if (backend == null) { + throw invalid("backend entries must not be null"); + } + validateId(backend.backendId(), "backendId"); + requireText(backend.displayName(), "backend displayName", 100); + if (backend.playerCount() < 0 || backend.playerCount() > 100000) { + throw invalid("backend playerCount must be between 0 and 100000"); + } + if (!ids.add(backend.backendId().toLowerCase(java.util.Locale.ROOT))) { + throw invalid("backend IDs must be unique within a snapshot"); + } + } + } + + private static void requireSession(StoredNode existing, UUID sessionId) { + if (!existing.sessionId.equals(sessionId)) { + throw new ValidationException("SESSION_MISMATCH", "Node session does not match current registration", + List.of()); + } + } + + private static void validateSession(UUID sessionId) { + if (sessionId == null) { + throw invalid("sessionId is required"); + } + } + + private static void validateId(String value, String field) { + if (value == null || !ID.matcher(value).matches()) { + throw invalid(field + " must match " + ID.pattern()); + } + } + + private static void validateProtocol(int version) { + if (version != Protocol.VERSION) { + throw new ValidationException("UNSUPPORTED_PROTOCOL", "Unsupported protocol version", + List.of("supported=" + Protocol.VERSION)); + } + } + + private static void validateCapabilities(Set advertised, Set required) { + validateCapabilitySet(advertised); + validateCapabilitySet(required); + List unavailable = new ArrayList<>(required); + unavailable.removeAll(SUPPORTED_CAPABILITIES); + if (!unavailable.isEmpty()) { + throw new ValidationException("INCOMPATIBLE_CAPABILITIES", "Required capabilities are unavailable", + unavailable.stream().sorted().toList()); + } + } + + private static void validateCapabilitySet(Set capabilities) { + if (capabilities == null || capabilities.size() > MAX_CAPABILITIES + || capabilities.stream().anyMatch(value -> value == null || !CAPABILITY.matcher(value).matches())) { + throw invalid("capabilities must contain at most 64 valid identifiers"); + } + } + + private static Set accepted(Set advertised) { + Set result = new HashSet<>(advertised); + result.retainAll(SUPPORTED_CAPABILITIES); + return Set.copyOf(result); + } + + private static void requireText(String value, String field, int max) { + if (value == null || value.isBlank() || value.length() > max) { + throw invalid(field + " must contain 1 to " + max + " characters"); + } + } + + private static ValidationException invalid(String detail) { + return new ValidationException("VALIDATION_ERROR", "Request validation failed", List.of(detail)); + } + + private record StoredNode(String nodeId, UUID sessionId, String displayName, String platform, + String pluginVersion, int protocolVersion, Set advertisedCapabilities, + Set acceptedCapabilities, List backends, + long snapshotSequence, Instant lastSeen, Instant lastAuthenticatedUpdate) { + private StoredNode { + advertisedCapabilities = Set.copyOf(advertisedCapabilities); + acceptedCapabilities = Set.copyOf(acceptedCapabilities); + backends = List.copyOf(backends); + } + } } diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java index 1557b093..9c94aa36 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java @@ -3,11 +3,15 @@ import com.bencodez.votingplugin.control.protocol.Heartbeat; import com.bencodez.votingplugin.control.protocol.NodeRegistration; import com.bencodez.votingplugin.control.protocol.NodeStatus; +import com.bencodez.votingplugin.control.protocol.PresenceSnapshot; import java.util.List; public interface NodeRegistry { RegistrationResult register(NodeRegistration registration); NodeStatus heartbeat(String nodeId, Heartbeat heartbeat); + SnapshotResult replacePresence(String nodeId, PresenceSnapshot snapshot); List list(int offset, int limit); + record RegistrationResult(NodeStatus node, boolean created) { } + record SnapshotResult(NodeStatus node, boolean applied) { } } diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index e14b28b4..5a9da838 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -1,124 +1,382 @@ package com.bencodez.votingplugin.control.http; -import com.bencodez.votingplugin.control.domain.*; -import com.bencodez.votingplugin.control.protocol.*; +import com.bencodez.votingplugin.control.auth.CredentialStore; +import com.bencodez.votingplugin.control.domain.NodeRegistry; +import com.bencodez.votingplugin.control.domain.ValidationException; +import com.bencodez.votingplugin.control.protocol.ControlIdentity; +import com.bencodez.votingplugin.control.protocol.Heartbeat; +import com.bencodez.votingplugin.control.protocol.NodeRegistration; +import com.bencodez.votingplugin.control.protocol.NodeStatus; +import com.bencodez.votingplugin.control.protocol.PresenceSnapshot; +import com.bencodez.votingplugin.control.protocol.ProtocolError; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.StreamReadConstraints; -import com.fasterxml.jackson.databind.*; -import com.sun.net.httpserver.*; -import java.io.*; -import java.net.*; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; +import java.time.Clock; import java.time.Instant; -import java.util.*; -import java.util.concurrent.*; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +/** Bounded HTTP adapter. Node writes are enrolled; node listings require the local admin credential. */ public final class ControlHttpServer implements AutoCloseable { - public static final int MAX_REQUEST_BYTES = 16 * 1024; + public static final int MAX_REQUEST_BYTES = 64 * 1024; + private static final String HEALTH = "/api/v1/health"; + private static final String NODES = "/api/v1/nodes"; + private static final String REGISTER = "/api/v1/nodes/register"; + private static final int MAX_AUTH_FAILURES_PER_MINUTE = 100; + private final HttpServer server; private final ObjectMapper json; private final NodeRegistry registry; private final ControlIdentity identity; - private final ExecutorService executor; + private final CredentialStore credentials; + private final ThreadPoolExecutor executor; + private final AuthFailureLimiter authLimiter; + + public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, + CredentialStore credentials) throws IOException { + this(address, registry, identity, credentials, Clock.systemUTC(), System::nanoTime); + } - public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity) throws IOException { - this.registry = Objects.requireNonNull(registry); this.identity = Objects.requireNonNull(identity); + ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, + CredentialStore credentials, Clock clock, java.util.function.LongSupplier nanoTime) throws IOException { + this.registry = Objects.requireNonNull(registry, "registry"); + this.identity = Objects.requireNonNull(identity, "identity"); + this.credentials = Objects.requireNonNull(credentials, "credentials"); + Objects.requireNonNull(clock, "clock"); json = new ObjectMapper(); json.findAndRegisterModules(); json.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - json.getFactory().setStreamReadConstraints(StreamReadConstraints.builder().maxNestingDepth(20).maxStringLength(4096).build()); - server = HttpServer.create(address, 0); - server.createContext("/api/v1/health", this::health); - server.createContext("/api/v1/nodes", this::nodes); - executor = Executors.newCachedThreadPool(); + json.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + json.getFactory().enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION.mappedFeature()); + json.getFactory().setStreamReadConstraints(StreamReadConstraints.builder() + .maxNestingDepth(20).maxStringLength(4096).maxNumberLength(64).build()); + authLimiter = new AuthFailureLimiter(nanoTime, MAX_AUTH_FAILURES_PER_MINUTE, TimeUnit.MINUTES.toNanos(1)); + server = HttpServer.create(address, 32); + server.createContext("/", this::handle); + ThreadFactory threads = runnable -> { + Thread thread = new Thread(runnable, "votingplugin-control-http"); + thread.setDaemon(true); + return thread; + }; + executor = new ThreadPoolExecutor(2, 8, 30, TimeUnit.SECONDS, + new ArrayBlockingQueue<>(32), threads, new ThreadPoolExecutor.AbortPolicy()); server.setExecutor(executor); } - public void start() { server.start(); } - public int port() { return server.getAddress().getPort(); } - @Override public void close() { server.stop(0); executor.shutdownNow(); } + public void start() { + server.start(); + } - private void health(HttpExchange exchange) throws IOException { - if (!method(exchange, "GET")) return; - send(exchange, 200, Map.of("status", "ok", "time", Instant.now(), "identity", identity)); + public int port() { + return server.getAddress().getPort(); } - private void nodes(HttpExchange exchange) throws IOException { + @Override + public void close() { + server.stop(0); + executor.shutdownNow(); try { - String path = exchange.getRequestURI().getPath(); - if (path.equals("/api/v1/nodes") && exchange.getRequestMethod().equals("GET")) { - Map query = query(exchange.getRequestURI().getRawQuery()); - int offset = integer(query.getOrDefault("offset", "0"), "offset"); - int limit = integer(query.getOrDefault("limit", "50"), "limit"); - List nodes = registry.list(offset, limit); - send(exchange, 200, Map.of("items", nodes, "offset", offset, "limit", limit)); - return; - } - if (path.equals("/api/v1/nodes/register")) { - if (!method(exchange, "POST")) return; - NodeRegistry.RegistrationResult result = registry.register(read(exchange, NodeRegistration.class)); - send(exchange, result.created() ? 201 : 200, Map.of("created", result.created(), "node", result.node())); - return; - } - String prefix = "/api/v1/nodes/"; - if (path.startsWith(prefix) && path.endsWith("/heartbeat")) { - if (!method(exchange, "PUT")) return; - String encodedId = path.substring(prefix.length(), path.length() - "/heartbeat".length()); - String nodeId = URLDecoder.decode(encodedId, StandardCharsets.UTF_8); - if (nodeId.contains("/")) throw invalid("nodeId path segment is invalid"); - send(exchange, 200, Map.of("node", registry.heartbeat(nodeId, read(exchange, Heartbeat.class)))); - return; - } - error(exchange, 404, "NOT_FOUND", "Endpoint not found", List.of()); + executor.awaitTermination(2, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private void handle(HttpExchange exchange) throws IOException { + try { + route(exchange); + } catch (AuthenticationException e) { + exchange.getResponseHeaders().set("WWW-Authenticate", "Bearer"); + error(exchange, e.rateLimited ? 429 : 401, e.rateLimited ? "AUTH_RATE_LIMITED" : "UNAUTHORIZED", + e.rateLimited ? "Too many authentication failures" : "Authentication failed", List.of()); } catch (ValidationException e) { - error(exchange, e.code().equals("NODE_NOT_FOUND") ? 404 : e.code().equals("UNSUPPORTED_PROTOCOL") ? 409 - : e.code().equals("UNSUPPORTED_MEDIA_TYPE") ? 415 : 400, - e.code(), e.getMessage(), e.details()); - } catch (JsonProcessingException e) { + int status = switch (e.code()) { + case "NODE_NOT_FOUND" -> 404; + case "UNSUPPORTED_PROTOCOL", "INCOMPATIBLE_CAPABILITIES", "SESSION_MISMATCH" -> 409; + case "UNSUPPORTED_MEDIA_TYPE" -> 415; + default -> 400; + }; + error(exchange, status, e.code(), e.getMessage(), e.details()); + } catch (JsonProcessingException | CharacterCodingException e) { error(exchange, 400, "MALFORMED_JSON", "Request body is not valid JSON", List.of()); } catch (RequestTooLargeException e) { - error(exchange, 413, "REQUEST_TOO_LARGE", "Request body exceeds " + MAX_REQUEST_BYTES + " bytes", List.of()); - } catch (NumberFormatException e) { - error(exchange, 400, "VALIDATION_ERROR", "Query parameter must be an integer", List.of(e.getMessage())); + error(exchange, 413, "REQUEST_TOO_LARGE", "Request body exceeds " + MAX_REQUEST_BYTES + " bytes", + List.of()); + } catch (IllegalArgumentException e) { + error(exchange, 400, "VALIDATION_ERROR", "Request validation failed", List.of()); + } catch (ResponseCompleteException ignored) { + // The route already sent its intentional response (for example, a 405). + } catch (RuntimeException e) { + error(exchange, 500, "INTERNAL_ERROR", "Request could not be completed", List.of()); + } finally { + exchange.close(); } } + private void route(HttpExchange exchange) throws IOException { + URI uri = exchange.getRequestURI(); + String path = uri.getPath(); + if (HEALTH.equals(path)) { + requireMethod(exchange, "GET"); + send(exchange, 200, Map.of("status", "ok", "time", Instant.now(), "identity", identity)); + return; + } + if (NODES.equals(path)) { + requireMethod(exchange, "GET"); + authenticateAdmin(exchange); + Map query = query(uri.getRawQuery()); + int offset = integer(query.getOrDefault("offset", "0"), "offset"); + int limit = integer(query.getOrDefault("limit", "50"), "limit"); + List nodes = registry.list(offset, limit); + send(exchange, 200, Map.of("items", nodes, "offset", offset, "limit", limit)); + return; + } + if (REGISTER.equals(path)) { + requireMethod(exchange, "POST"); + NodeRegistration registration = read(exchange, NodeRegistration.class); + if (registration == null) { + throw new ValidationException("VALIDATION_ERROR", "Request validation failed", + List.of("registration is required")); + } + authenticateNode(exchange, registration.nodeId()); + NodeRegistry.RegistrationResult result = registry.register(registration); + send(exchange, result.created() ? 201 : 200, + Map.of("created", result.created(), "node", result.node(), "identity", identity)); + return; + } + + String prefix = NODES + "/"; + if (path != null && path.startsWith(prefix)) { + String remainder = path.substring(prefix.length()); + String[] segments = remainder.split("/", -1); + if (segments.length == 2 && ("heartbeat".equals(segments[1]) || "presence".equals(segments[1]))) { + String nodeId = decodePathSegment(segments[0]); + if ("heartbeat".equals(segments[1])) { + requireMethod(exchange, "PUT"); + authenticateNode(exchange, nodeId); + send(exchange, 200, Map.of("node", registry.heartbeat(nodeId, read(exchange, Heartbeat.class)))); + } else { + requireMethod(exchange, "PUT"); + authenticateNode(exchange, nodeId); + NodeRegistry.SnapshotResult result = registry.replacePresence(nodeId, + read(exchange, PresenceSnapshot.class)); + send(exchange, 200, Map.of("applied", result.applied(), "node", result.node())); + } + return; + } + } + error(exchange, 404, "NOT_FOUND", "Endpoint not found", List.of()); + } + private T read(HttpExchange exchange, Class type) throws IOException { String contentType = exchange.getRequestHeaders().getFirst("Content-Type"); - if (contentType == null || !contentType.toLowerCase(Locale.ROOT).startsWith("application/json")) + if (contentType == null || !contentType.toLowerCase(Locale.ROOT).startsWith("application/json")) { throw new ValidationException("UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json", List.of()); - String length = exchange.getRequestHeaders().getFirst("Content-Length"); - if (length != null && Long.parseLong(length) > MAX_REQUEST_BYTES) throw new RequestTooLargeException(); + } + List lengths = exchange.getRequestHeaders().get("Content-Length"); + if (lengths != null && lengths.size() > 1) { + throw new IllegalArgumentException("Ambiguous Content-Length"); + } + if (lengths != null && !lengths.isEmpty()) { + long length; + try { + length = Long.parseLong(lengths.get(0)); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid Content-Length"); + } + if (length < 0) { + throw new IllegalArgumentException("Invalid Content-Length"); + } + if (length > MAX_REQUEST_BYTES) { + throw new RequestTooLargeException(); + } + } byte[] bytes; - try (InputStream input = exchange.getRequestBody()) { bytes = input.readNBytes(MAX_REQUEST_BYTES + 1); } - if (bytes.length > MAX_REQUEST_BYTES) throw new RequestTooLargeException(); - return json.readValue(bytes, type); + try (InputStream input = exchange.getRequestBody(); ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] buffer = new byte[4096]; + int total = 0; + int count; + while ((count = input.read(buffer)) != -1) { + total += count; + if (total > MAX_REQUEST_BYTES) { + throw new RequestTooLargeException(); + } + output.write(buffer, 0, count); + } + bytes = output.toByteArray(); + } + String text = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)).toString(); + return json.readValue(text, type); + } + + private void authenticateNode(HttpExchange exchange, String nodeId) { + authenticate(exchange, token -> credentials.verifyNode(nodeId, token)); + } + + private void authenticateAdmin(HttpExchange exchange) { + authenticate(exchange, credentials::verifyAdmin); + } + + private void authenticate(HttpExchange exchange, java.util.function.Predicate verifier) { + if (!authLimiter.allowAttempt()) { + throw new AuthenticationException(true); + } + String token = bearer(exchange); + if (!verifier.test(token)) { + authLimiter.recordFailure(); + throw new AuthenticationException(false); + } } - private boolean method(HttpExchange exchange, String expected) throws IOException { - if (exchange.getRequestMethod().equals(expected)) return true; + + private static String bearer(HttpExchange exchange) { + List values = exchange.getRequestHeaders().get("Authorization"); + if (values == null || values.size() != 1) { + return null; + } + String value = values.get(0); + return value.regionMatches(true, 0, "Bearer ", 0, 7) && value.length() > 7 + ? value.substring(7) : null; + } + + private void requireMethod(HttpExchange exchange, String expected) throws IOException { + if (expected.equals(exchange.getRequestMethod())) { + return; + } exchange.getResponseHeaders().set("Allow", expected); - error(exchange, 405, "METHOD_NOT_ALLOWED", "Expected " + expected, List.of()); return false; + error(exchange, 405, "METHOD_NOT_ALLOWED", "Method is not allowed", List.of("allowed=" + expected)); + throw new ResponseCompleteException(); } + private void send(HttpExchange exchange, int status, Object value) throws IOException { byte[] body = json.writeValueAsBytes(value); exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); exchange.getResponseHeaders().set("Cache-Control", "no-store"); + exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff"); exchange.sendResponseHeaders(status, body.length); - try (OutputStream output = exchange.getResponseBody()) { output.write(body); } + try (OutputStream output = exchange.getResponseBody()) { + output.write(body); + } } - private void error(HttpExchange exchange, int status, String code, String message, List details) throws IOException { + + private void error(HttpExchange exchange, int status, String code, String message, List details) + throws IOException { send(exchange, status, Map.of("error", new ProtocolError(code, message, details))); } - private static int integer(String value, String name) { try { return Integer.parseInt(value); } catch (NumberFormatException e) { throw new NumberFormatException(name); } } + + private static int integer(String value, String name) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(name + " must be an integer"); + } + } + private static Map query(String raw) { Map result = new HashMap<>(); - if (raw == null || raw.isBlank()) return result; - for (String item : raw.split("&")) { + if (raw == null || raw.isBlank()) { + return result; + } + for (String item : raw.split("&", -1)) { String[] pair = item.split("=", 2); - result.put(URLDecoder.decode(pair[0], StandardCharsets.UTF_8), URLDecoder.decode(pair.length == 2 ? pair[1] : "", StandardCharsets.UTF_8)); + String key = decodeQuery(pair[0]); + String value = decodeQuery(pair.length == 2 ? pair[1] : ""); + if (result.putIfAbsent(key, value) != null) { + throw new IllegalArgumentException("Duplicate query parameter"); + } } return result; } - private static ValidationException invalid(String detail) { return new ValidationException("VALIDATION_ERROR", "Request validation failed", List.of(detail)); } + + private static String decodeQuery(String value) { + try { + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid query encoding"); + } + } + + private static String decodePathSegment(String value) { + String decoded; + try { + decoded = URLDecoder.decode(value, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid path encoding"); + } + if (decoded.isBlank() || decoded.contains("/") || decoded.contains("\\")) { + throw new IllegalArgumentException("Invalid node path"); + } + return decoded; + } + + @SuppressWarnings("serial") private static final class RequestTooLargeException extends IOException { } + @SuppressWarnings("serial") + private static final class AuthenticationException extends RuntimeException { + private final boolean rateLimited; + private AuthenticationException(boolean rateLimited) { + this.rateLimited = rateLimited; + } + } + @SuppressWarnings("serial") + private static final class ResponseCompleteException extends RuntimeException { } + + static final class AuthFailureLimiter { + private final java.util.function.LongSupplier nanoTime; + private final int maximum; + private final long windowNanos; + private long windowStarted; + private int failures; + + AuthFailureLimiter(java.util.function.LongSupplier nanoTime, int maximum, long windowNanos) { + this.nanoTime = nanoTime; + this.maximum = maximum; + this.windowNanos = windowNanos; + this.windowStarted = nanoTime.getAsLong(); + } + + synchronized boolean allowAttempt() { + resetExpired(); + return failures < maximum; + } + + synchronized void recordFailure() { + resetExpired(); + if (failures < maximum) { + failures++; + } + } + + private void resetExpired() { + long now = nanoTime.getAsLong(); + if (now - windowStarted >= windowNanos || now < windowStarted) { + windowStarted = now; + failures = 0; + } + } + } } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/BackendServerIdentity.java b/src/main/java/com/bencodez/votingplugin/control/protocol/BackendServerIdentity.java new file mode 100644 index 00000000..c579ee25 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/BackendServerIdentity.java @@ -0,0 +1,5 @@ +package com.bencodez.votingplugin.control.protocol; + +/** Safe backend metadata observed by one proxy. Addresses are intentionally excluded. */ +public record BackendServerIdentity(String backendId, String displayName, boolean presenceKnown, + boolean available, int playerCount) { } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/Heartbeat.java b/src/main/java/com/bencodez/votingplugin/control/protocol/Heartbeat.java index a3bf5880..e815c289 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/Heartbeat.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/Heartbeat.java @@ -1,9 +1,13 @@ package com.bencodez.votingplugin.control.protocol; import java.util.Set; +import java.util.UUID; -public record Heartbeat(int protocolVersion, Set capabilities) { +/** A heartbeat replaces the node's previously advertised capability set. */ +public record Heartbeat(UUID sessionId, int protocolVersion, Set capabilities, + Set requiredCapabilities) { public Heartbeat { - capabilities = capabilities == null ? null : Set.copyOf(capabilities); + capabilities = capabilities == null ? Set.of() : Set.copyOf(capabilities); + requiredCapabilities = requiredCapabilities == null ? Set.of() : Set.copyOf(requiredCapabilities); } } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/NodeRegistration.java b/src/main/java/com/bencodez/votingplugin/control/protocol/NodeRegistration.java index f4bdf8a8..f5dce7ea 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/NodeRegistration.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/NodeRegistration.java @@ -1,10 +1,14 @@ package com.bencodez.votingplugin.control.protocol; import java.util.Set; +import java.util.UUID; -public record NodeRegistration(String nodeId, String displayName, String platform, - String pluginVersion, int protocolVersion, Set capabilities) { +/** Immutable proxy registration. Retries for the same node/session are idempotent. */ +public record NodeRegistration(String nodeId, UUID sessionId, String displayName, String platform, + String pluginVersion, int protocolVersion, Set capabilities, + Set requiredCapabilities) { public NodeRegistration { capabilities = capabilities == null ? Set.of() : Set.copyOf(capabilities); + requiredCapabilities = requiredCapabilities == null ? Set.of() : Set.copyOf(requiredCapabilities); } } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/NodeStatus.java b/src/main/java/com/bencodez/votingplugin/control/protocol/NodeStatus.java index a20f0eac..2a5a67fb 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/NodeStatus.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/NodeStatus.java @@ -1,11 +1,17 @@ package com.bencodez.votingplugin.control.protocol; import java.time.Instant; +import java.util.List; import java.util.Set; +import java.util.UUID; -public record NodeStatus(String nodeId, String displayName, String platform, String pluginVersion, - int protocolVersion, Set capabilities, Instant lastSeen, boolean online) { +public record NodeStatus(String nodeId, UUID sessionId, String displayName, String platform, String pluginVersion, + int protocolVersion, Set advertisedCapabilities, Set acceptedCapabilities, + List backends, long snapshotSequence, + Instant lastSeen, Instant lastAuthenticatedUpdate, boolean online) { public NodeStatus { - capabilities = Set.copyOf(capabilities); + advertisedCapabilities = Set.copyOf(advertisedCapabilities); + acceptedCapabilities = Set.copyOf(acceptedCapabilities); + backends = List.copyOf(backends); } } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/PresenceSnapshot.java b/src/main/java/com/bencodez/votingplugin/control/protocol/PresenceSnapshot.java new file mode 100644 index 00000000..70472e83 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/PresenceSnapshot.java @@ -0,0 +1,12 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.util.List; +import java.util.UUID; + +/** Full replacement snapshot for one proxy session. */ +public record PresenceSnapshot(UUID sessionId, int protocolVersion, long sequence, + List backends) { + public PresenceSnapshot { + backends = backends == null ? List.of() : List.copyOf(backends); + } +} diff --git a/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java new file mode 100644 index 00000000..a98c60d6 --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java @@ -0,0 +1,63 @@ +package com.bencodez.votingplugin.control; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +class ControlApplicationTest { + @TempDir Path directory; + + @Test void identityIsStableAndConcurrentFirstStartupPublishesOneWinner() throws Exception { + int count = 24; + CountDownLatch ready = new CountDownLatch(count); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(count); + List results = java.util.Collections.synchronizedList(new ArrayList<>()); + var executor = Executors.newFixedThreadPool(count); + try { + for (int i = 0; i < count; i++) { + executor.submit(() -> { + ready.countDown(); + try { + start.await(); + results.add(ControlApplication.loadIdentity(directory)); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + done.countDown(); + } + }); + } + ready.await(); + start.countDown(); + done.await(); + } finally { + executor.shutdownNow(); + } + Set unique = new HashSet<>(results); + assertEquals(1, unique.size()); + assertEquals(unique.iterator().next(), ControlApplication.loadIdentity(directory)); + } + + @Test void corruptIdentityFailsClosed() throws Exception { + Files.writeString(directory.resolve("instance-id"), "not-a-uuid"); + assertThrows(java.io.IOException.class, () -> ControlApplication.loadIdentity(directory)); + } + + @Test void startupConfigurationValidatesAllBounds() { + assertThrows(IllegalArgumentException.class, + () -> ControlApplication.Configuration.from(java.util.Map.of("CONTROL_PORT", "70000"))); + assertThrows(IllegalArgumentException.class, + () -> ControlApplication.Configuration.from(java.util.Map.of("CONTROL_OFFLINE_TIMEOUT_SECONDS", "0"))); + assertThrows(IllegalArgumentException.class, + () -> ControlApplication.Configuration.from(java.util.Map.of("CONTROL_REQUEST_TIMEOUT_SECONDS", "61"))); + } +} diff --git a/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java new file mode 100644 index 00000000..8c0d6c05 --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java @@ -0,0 +1,40 @@ +package com.bencodez.votingplugin.control.auth; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +class CredentialStoreTest { + @TempDir Path directory; + + @Test void enrollmentIsBoundRotatableRevocableAndStoredAsVerifier() throws Exception { + CredentialStore store = new CredentialStore(directory); + String first = store.rotateNode("proxy-a"); + assertTrue(store.verifyNode("proxy-a", first)); + assertFalse(store.verifyNode("proxy-b", first)); + assertFalse(store.verifyNode("missing", "vpctl_node_wrong")); + String persisted = Files.readString(directory.resolve("credentials.json")); + assertFalse(persisted.contains(first)); + assertFalse(persisted.contains("vpctl_node_")); + + String rotated = store.rotateNode("proxy-a"); + assertFalse(store.verifyNode("proxy-a", first)); + assertTrue(store.verifyNode("proxy-a", rotated)); + store.revokeNode("proxy-a"); + assertFalse(store.verifyNode("proxy-a", rotated)); + } + + @Test void adminCredentialRotatesAndRawSecretIsNeverPersisted() throws Exception { + CredentialStore store = new CredentialStore(directory); + assertFalse(store.hasAdmin()); + String token = store.rotateAdmin(); + assertTrue(store.hasAdmin()); + assertTrue(store.verifyAdmin(token)); + assertFalse(Files.readString(directory.resolve("credentials.json")).contains(token)); + String replacement = store.rotateAdmin(); + assertFalse(store.verifyAdmin(token)); + assertTrue(store.verifyAdmin(replacement)); + } +} diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java index 2da2faf8..629dcacc 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java @@ -1,36 +1,146 @@ package com.bencodez.votingplugin.control.domain; -import com.bencodez.votingplugin.control.protocol.*; -import org.junit.jupiter.api.Test; -import java.time.*; +import com.bencodez.votingplugin.control.protocol.BackendServerIdentity; +import com.bencodez.votingplugin.control.protocol.Heartbeat; +import com.bencodez.votingplugin.control.protocol.NodeRegistration; +import com.bencodez.votingplugin.control.protocol.PresenceSnapshot; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.*; class InMemoryNodeRegistryTest { - @Test void heartbeatUpdatesCapabilitiesAndOfflineStateTracksTimeout() { - MutableClock clock = new MutableClock(Instant.parse("2026-08-24T00:00:00Z")); - InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofSeconds(90)); - registry.register(new NodeRegistration("proxy-a", "Proxy A", "VELOCITY", "6.20", 1, Set.of("status.read"))); - clock.advance(Duration.ofSeconds(89)); - assertTrue(registry.list(0, 10).get(0).online()); - NodeStatus heartbeat = registry.heartbeat("proxy-a", new Heartbeat(1, Set.of("status.read", "servers.list"))); - assertEquals(Set.of("status.read", "servers.list"), heartbeat.capabilities()); - clock.advance(Duration.ofSeconds(90)); - assertFalse(registry.list(0, 10).get(0).online()); - } - - @Test void rejectsInvalidIdentifiersAndUnknownNodes() { - InMemoryNodeRegistry registry = new InMemoryNodeRegistry(Clock.systemUTC(), Duration.ofSeconds(90)); - ValidationException invalid = assertThrows(ValidationException.class, () -> registry.register( - new NodeRegistration("bad/id", "Proxy", "BUNGEECORD", "1", 1, Set.of()))); - assertEquals("VALIDATION_ERROR", invalid.code()); - assertEquals("NODE_NOT_FOUND", assertThrows(ValidationException.class, - () -> registry.heartbeat("missing", new Heartbeat(1, null))).code()); + private final MutableClock clock = new MutableClock(Instant.parse("2026-08-25T00:00:00Z")); + private final InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofSeconds(90)); + private final UUID session = UUID.fromString("00000000-0000-0000-0000-000000000001"); + + @Test void registrationNegotiatesCapabilitiesAndDefensivelyCopiesInput() { + Set capabilities = new HashSet<>(Set.of("discovery.read", "future.feature")); + var result = registry.register(registration("proxy-a", session, capabilities, Set.of("discovery.read"))); + capabilities.clear(); + assertTrue(result.created()); + assertEquals(Set.of("discovery.read", "future.feature"), result.node().advertisedCapabilities()); + assertEquals(Set.of("discovery.read"), result.node().acceptedCapabilities()); + assertThrows(UnsupportedOperationException.class, + () -> result.node().advertisedCapabilities().add("changed")); + } + + @Test void heartbeatReplacesCapabilitiesAndRejectsUnavailableRequiredCapability() { + registry.register(registration("proxy-a", session, Set.of("discovery.read"), Set.of())); + var updated = registry.heartbeat("proxy-a", new Heartbeat(session, 1, Set.of("presence.snapshot"), Set.of())); + assertEquals(Set.of("presence.snapshot"), updated.advertisedCapabilities()); + ValidationException error = assertThrows(ValidationException.class, + () -> registry.heartbeat("proxy-a", new Heartbeat(session, 1, Set.of(), Set.of("future.required")))); + assertEquals("INCOMPATIBLE_CAPABILITIES", error.code()); + } + + @Test void snapshotIsReplacementRejectsDuplicateIdsAndIgnoresReplayOrOutOfOrderDelivery() { + registry.register(registration("proxy-a", session, Set.of("presence.snapshot"), Set.of())); + var first = registry.replacePresence("proxy-a", snapshot(2, backend("lobby"), backend("survival"))); + assertTrue(first.applied()); + var replay = registry.replacePresence("proxy-a", snapshot(2, backend("other"))); + assertFalse(replay.applied()); + assertEquals(List.of("lobby", "survival"), replay.node().backends().stream() + .map(BackendServerIdentity::backendId).toList()); + var stale = registry.replacePresence("proxy-a", snapshot(1, backend("other"))); + assertFalse(stale.applied()); + assertThrows(ValidationException.class, + () -> registry.replacePresence("proxy-a", snapshot(3, backend("Lobby"), backend("lobby")))); + } + + @Test void newSessionAtomicallyReplacesRegistrationAndClearsOldTopology() { + registry.register(registration("proxy-a", session, Set.of("presence.snapshot"), Set.of())); + registry.replacePresence("proxy-a", snapshot(1, backend("lobby"))); + UUID restarted = UUID.fromString("00000000-0000-0000-0000-000000000002"); + var replacement = registry.register(registration("proxy-a", restarted, Set.of(), Set.of())); + assertFalse(replacement.created()); + assertEquals(restarted, replacement.node().sessionId()); + assertTrue(replacement.node().backends().isEmpty()); + assertEquals(-1, replacement.node().snapshotSequence()); + assertEquals("SESSION_MISMATCH", assertThrows(ValidationException.class, + () -> registry.heartbeat("proxy-a", new Heartbeat(session, 1, Set.of(), Set.of()))).code()); + } + + @Test void duplicateRegistrationIsAtomicUnderConcurrency() throws Exception { + int count = 32; + CountDownLatch ready = new CountDownLatch(count); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(count); + List created = java.util.Collections.synchronizedList(new ArrayList<>()); + var executor = Executors.newFixedThreadPool(count); + try { + for (int i = 0; i < count; i++) { + executor.submit(() -> { + ready.countDown(); + try { + start.await(); + created.add(registry.register(registration("proxy-a", session, Set.of(), Set.of())).created()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + done.countDown(); + } + }); + } + ready.await(); + start.countDown(); + done.await(); + } finally { + executor.shutdownNow(); + } + assertEquals(1, created.stream().filter(Boolean::booleanValue).count()); + assertEquals(1, registry.list(0, 100).size()); + } + + @Test void paginationIsSortedAndBoundedAndOfflineBoundaryIsExplicit() { + registry.register(registration("proxy-b", UUID.randomUUID(), Set.of(), Set.of())); + registry.register(registration("proxy-a", session, Set.of(), Set.of())); + assertEquals("proxy-b", registry.list(1, 1).get(0).nodeId()); + assertThrows(ValidationException.class, () -> registry.list(0, 101)); + clock.advance(Duration.ofSeconds(89).plusMillis(999)); + assertTrue(registry.list(0, 1).get(0).online()); + clock.advance(Duration.ofMillis(1)); + assertFalse(registry.list(0, 1).get(0).online()); + } + + @Test void validatesNodeBackendProtocolCollectionAndStringBounds() { + assertThrows(ValidationException.class, + () -> registry.register(registration("bad/id", session, Set.of(), Set.of()))); + assertEquals("UNSUPPORTED_PROTOCOL", assertThrows(ValidationException.class, + () -> registry.register(new NodeRegistration("proxy", session, "Proxy", "VELOCITY", "1", 2, + Set.of(), Set.of()))).code()); + registry.register(registration("proxy-a", session, Set.of(), Set.of())); + assertThrows(ValidationException.class, () -> registry.replacePresence("proxy-a", + new PresenceSnapshot(session, 1, 1, List.of(new BackendServerIdentity("bad/id", "x", true, true, 0))))); + } + + private NodeRegistration registration(String nodeId, UUID registrationSession, Set capabilities, + Set required) { + return new NodeRegistration(nodeId, registrationSession, "Proxy A", "VELOCITY", "7.1.2", 1, + capabilities, required); + } + + private PresenceSnapshot snapshot(long sequence, BackendServerIdentity... backends) { + return new PresenceSnapshot(session, 1, sequence, List.of(backends)); + } + + private static BackendServerIdentity backend(String id) { + return new BackendServerIdentity(id, id, true, true, 1); } private static final class MutableClock extends Clock { private Instant instant; - MutableClock(Instant instant) { this.instant = instant; } + private MutableClock(Instant instant) { this.instant = instant; } void advance(Duration duration) { instant = instant.plus(duration); } @Override public ZoneId getZone() { return ZoneOffset.UTC; } @Override public Clock withZone(ZoneId zone) { return this; } diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 83ddc892..5c90729b 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -1,89 +1,172 @@ package com.bencodez.votingplugin.control.http; +import com.bencodez.votingplugin.control.auth.CredentialStore; import com.bencodez.votingplugin.control.domain.InMemoryNodeRegistry; -import com.bencodez.votingplugin.control.protocol.*; -import com.fasterxml.jackson.databind.*; -import org.junit.jupiter.api.*; -import java.net.*; -import java.net.http.*; -import java.time.*; +import com.bencodez.votingplugin.control.protocol.ControlIdentity; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Duration; import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import static org.junit.jupiter.api.Assertions.*; class ControlHttpServerTest { + private static final String SESSION = "00000000-0000-0000-0000-000000000001"; + @TempDir Path directory; private ControlHttpServer server; + private CredentialStore credentials; + private String nodeToken; + private String adminToken; private HttpClient client; private ObjectMapper json; private URI base; @BeforeEach void start() throws Exception { + credentials = new CredentialStore(directory); + nodeToken = credentials.rotateNode("proxy-a"); + adminToken = credentials.rotateAdmin(); server = new ControlHttpServer(new InetSocketAddress("127.0.0.1", 0), new InMemoryNodeRegistry(Clock.systemUTC(), Duration.ofSeconds(90)), - new ControlIdentity(UUID.fromString("00000000-0000-0000-0000-000000000001"), "test", 1)); + new ControlIdentity(UUID.fromString("00000000-0000-0000-0000-000000000099"), "test", 1), + credentials); server.start(); base = URI.create("http://127.0.0.1:" + server.port()); - client = HttpClient.newHttpClient(); + client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(); json = new ObjectMapper(); } - @AfterEach void stop() { server.close(); } - - @Test void healthIsVersionedAndHealthy() throws Exception { - HttpResponse response = get("/api/v1/health"); - assertEquals(200, response.statusCode()); - JsonNode body = json.readTree(response.body()); - assertEquals("ok", body.get("status").asText()); - assertEquals(1, body.at("/identity/protocolVersion").asInt()); + + @AfterEach void stop() { + if (server != null) { + server.close(); + } + } + + @Test void healthRouteIsExactUnknownRoutesAreStructuredAndMethodsAreIntentional() throws Exception { + assertEquals(200, get("/api/v1/health", null).statusCode()); + assertError(get("/api/v1/health/anything", null), 404, "NOT_FOUND"); + assertError(get("/api/v1/nodes/register/anything", null), 404, "NOT_FOUND"); + HttpResponse method = send("POST", "/api/v1/health", "{}", null); + assertError(method, 405, "METHOD_NOT_ALLOWED"); + assertEquals("GET", method.headers().firstValue("Allow").orElseThrow()); + assertError(send("GET", "/api/v1/nodes/register", null, null), 405, "METHOD_NOT_ALLOWED"); + assertError(send("POST", "/api/v1/nodes/proxy-a/heartbeat", "{}", null), 405, + "METHOD_NOT_ALLOWED"); + } + + @Test void validEnrollmentAuthenticatesRegistrationHeartbeatPresenceAndAdminListing() throws Exception { + HttpResponse registered = send("POST", "/api/v1/nodes/register", registration(), nodeToken); + assertEquals(201, registered.statusCode()); + JsonNode node = json.readTree(registered.body()).get("node"); + assertEquals("proxy-a", node.get("nodeId").asText()); + assertEquals("presence.snapshot", node.get("acceptedCapabilities").get(0).asText()); + + String heartbeat = "{\"sessionId\":\"" + SESSION + "\",\"protocolVersion\":1," + + "\"capabilities\":[\"discovery.read\"],\"requiredCapabilities\":[]}"; + assertEquals(200, send("PUT", "/api/v1/nodes/proxy-a/heartbeat", heartbeat, nodeToken).statusCode()); + String snapshot = "{\"sessionId\":\"" + SESSION + "\",\"protocolVersion\":1,\"sequence\":1," + + "\"backends\":[{\"backendId\":\"lobby\",\"displayName\":\"Lobby\"," + + "\"presenceKnown\":true,\"available\":true,\"playerCount\":3}]}"; + assertEquals(200, send("PUT", "/api/v1/nodes/proxy-a/presence", snapshot, nodeToken).statusCode()); + JsonNode listed = json.readTree(get("/api/v1/nodes?offset=0&limit=10", adminToken).body()); + assertEquals("lobby", listed.at("/items/0/backends/0/backendId").asText()); + assertTrue(listed.at("/items/0/online").asBoolean()); } - @Test void registrationIsCreatedThenUpdatedAndListed() throws Exception { - String first = "{\"nodeId\":\"proxy-a\",\"displayName\":\"Proxy A\",\"platform\":\"VELOCITY\",\"pluginVersion\":\"6.20\",\"protocolVersion\":1,\"capabilities\":[\"status.read\"]}"; - assertEquals(201, send("POST", "/api/v1/nodes/register", first).statusCode()); - String update = first.replace("Proxy A", "Main Proxy"); - HttpResponse updated = send("POST", "/api/v1/nodes/register", update); - assertEquals(200, updated.statusCode()); - assertFalse(json.readTree(updated.body()).get("created").asBoolean()); - JsonNode listed = json.readTree(get("/api/v1/nodes?limit=10").body()); - assertEquals(1, listed.get("items").size()); - assertEquals("Main Proxy", listed.at("/items/0/displayName").asText()); + @Test void missingInvalidWrongNodeRevokedAndRotatedCredentialsFailWithoutDisclosure() throws Exception { + assertAuthFailure(send("POST", "/api/v1/nodes/register", registration(), null)); + assertAuthFailure(send("POST", "/api/v1/nodes/register", registration(), "wrong")); + String other = credentials.rotateNode("proxy-b"); + assertAuthFailure(send("POST", "/api/v1/nodes/register", registration(), other)); + credentials.revokeNode("proxy-a"); + assertAuthFailure(send("POST", "/api/v1/nodes/register", registration(), nodeToken)); + String rotated = credentials.rotateNode("proxy-a"); + assertEquals(201, send("POST", "/api/v1/nodes/register", registration(), rotated).statusCode()); + assertAuthFailure(get("/api/v1/nodes", nodeToken)); + assertEquals(200, get("/api/v1/nodes", adminToken).statusCode()); } - @Test void heartbeatUpdatesLastSeenAndCapabilities() throws Exception { - register("proxy-a", "[\"status.read\"]"); - HttpResponse response = send("PUT", "/api/v1/nodes/proxy-a/heartbeat", - "{\"protocolVersion\":1,\"capabilities\":[\"servers.list\"]}"); - assertEquals(200, response.statusCode()); - JsonNode node = json.readTree(response.body()).get("node"); - assertEquals("servers.list", node.get("capabilities").get(0).asText()); - assertNotNull(Instant.parse(node.get("lastSeen").asText())); + @Test void malformedEmptyNullDuplicateNestedOversizedAndLongPayloadsAreDeterministic() throws Exception { + assertError(send("POST", "/api/v1/nodes/register", "{", nodeToken), 400, "MALFORMED_JSON"); + assertError(send("POST", "/api/v1/nodes/register", "", nodeToken), 400, "MALFORMED_JSON"); + assertError(send("POST", "/api/v1/nodes/register", "null", nodeToken), 400, "VALIDATION_ERROR"); + String duplicate = registration().replaceFirst("\\{", "{\"nodeId\":\"proxy-a\","); + assertError(send("POST", "/api/v1/nodes/register", duplicate, nodeToken), 400, "MALFORMED_JSON"); + String nested = "[".repeat(25) + "0" + "]".repeat(25); + assertError(send("POST", "/api/v1/nodes/register", nested, nodeToken), 400, "MALFORMED_JSON"); + assertError(send("POST", "/api/v1/nodes/register", " ".repeat(ControlHttpServer.MAX_REQUEST_BYTES + 1), + nodeToken), 413, "REQUEST_TOO_LARGE"); + assertError(send("POST", "/api/v1/nodes/register", registration().replace("Proxy A", "x".repeat(101)), + nodeToken), 400, "VALIDATION_ERROR"); + HttpRequest invalidUtf8 = HttpRequest.newBuilder(base.resolve("/api/v1/nodes/register")) + .header("Content-Type", "application/json").header("Authorization", "Bearer " + nodeToken) + .POST(HttpRequest.BodyPublishers.ofByteArray(new byte[] {(byte) 0xc3, (byte) 0x28})).build(); + assertError(client.send(invalidUtf8, HttpResponse.BodyHandlers.ofString()), 400, "MALFORMED_JSON"); } - @Test void malformedPayloadInvalidIdAndOversizedBodyReturnStructuredErrors() throws Exception { - assertError(send("POST", "/api/v1/nodes/register", "{"), 400, "MALFORMED_JSON"); - String invalid = "{\"nodeId\":\"bad/id\",\"displayName\":\"x\",\"platform\":\"OTHER\",\"pluginVersion\":\"1\",\"protocolVersion\":1}"; - assertError(send("POST", "/api/v1/nodes/register", invalid), 400, "VALIDATION_ERROR"); - assertError(send("POST", "/api/v1/nodes/register", " ".repeat(ControlHttpServer.MAX_REQUEST_BYTES + 1)), 413, "REQUEST_TOO_LARGE"); + @Test void unknownFieldsAreAdditivelyCompatibleButUnsupportedProtocolAndRequirementsAreExplicit() throws Exception { + assertEquals(201, send("POST", "/api/v1/nodes/register", + registration().replace("}", ",\"futureField\":{\"value\":true}}"), nodeToken).statusCode()); + credentials.rotateNode("proxy-a"); + nodeToken = credentials.rotateNode("proxy-a"); + assertError(send("POST", "/api/v1/nodes/register", registration().replace("\"protocolVersion\":1", + "\"protocolVersion\":2"), nodeToken), 409, "UNSUPPORTED_PROTOCOL"); + assertError(send("POST", "/api/v1/nodes/register", registration().replace("\"requiredCapabilities\":[]", + "\"requiredCapabilities\":[\"future.required\"]"), nodeToken), 409, + "INCOMPATIBLE_CAPABILITIES"); } - @Test void ignoresUnknownJsonFieldsForForwardCompatibility() throws Exception { - String payload = "{\"nodeId\":\"future-proxy\",\"displayName\":\"Future\",\"platform\":\"BUNGEECORD\",\"pluginVersion\":\"1\",\"protocolVersion\":1,\"futureField\":{\"value\":true}}"; - assertEquals(201, send("POST", "/api/v1/nodes/register", payload).statusCode()); + @Test void queryValidationAndShutdownAreBounded() throws Exception { + assertError(get("/api/v1/nodes?limit=101", adminToken), 400, "VALIDATION_ERROR"); + assertError(get("/api/v1/nodes?limit=1&limit=2", adminToken), 400, "VALIDATION_ERROR"); + server.close(); + server = null; + assertTrue(Thread.getAllStackTraces().keySet().stream() + .noneMatch(thread -> thread.isAlive() && "votingplugin-control-http".equals(thread.getName()))); } - @Test void rejectsInvalidCapabilitiesAndProtocolVersions() throws Exception { - assertError(register("proxy-a", "[\"INVALID CAPABILITY\"]"), 400, "VALIDATION_ERROR"); - String payload = "{\"nodeId\":\"proxy-a\",\"displayName\":\"A\",\"platform\":\"OTHER\",\"pluginVersion\":\"1\",\"protocolVersion\":2}"; - assertError(send("POST", "/api/v1/nodes/register", payload), 409, "UNSUPPORTED_PROTOCOL"); + private String registration() { + return "{\"nodeId\":\"proxy-a\",\"sessionId\":\"" + SESSION + "\",\"displayName\":\"Proxy A\"," + + "\"platform\":\"VELOCITY\",\"pluginVersion\":\"7.1.2\",\"protocolVersion\":1," + + "\"capabilities\":[\"presence.snapshot\"],\"requiredCapabilities\":[]}"; } - private HttpResponse register(String id, String capabilities) throws Exception { - return send("POST", "/api/v1/nodes/register", "{\"nodeId\":\"" + id + "\",\"displayName\":\"A\",\"platform\":\"OTHER\",\"pluginVersion\":\"1\",\"protocolVersion\":1,\"capabilities\":" + capabilities + "}"); + private HttpResponse get(String path, String token) throws Exception { + return send("GET", path, null, token); } - private HttpResponse get(String path) throws Exception { return client.send(HttpRequest.newBuilder(base.resolve(path)).GET().build(), HttpResponse.BodyHandlers.ofString()); } - private HttpResponse send(String method, String path, String body) throws Exception { - return client.send(HttpRequest.newBuilder(base.resolve(path)).header("Content-Type", "application/json") - .method(method, HttpRequest.BodyPublishers.ofString(body)).build(), HttpResponse.BodyHandlers.ofString()); + + private HttpResponse send(String method, String path, String body, String token) throws Exception { + HttpRequest.Builder builder = HttpRequest.newBuilder(base.resolve(path)).timeout(Duration.ofSeconds(3)); + if (token != null) { + builder.header("Authorization", "Bearer " + token); + } + if (body != null) { + builder.header("Content-Type", "application/json"); + } + builder.method(method, body == null ? HttpRequest.BodyPublishers.noBody() + : HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)); + return client.send(builder.build(), HttpResponse.BodyHandlers.ofString()); } + + private void assertAuthFailure(HttpResponse response) throws Exception { + assertError(response, 401, "UNAUTHORIZED"); + assertEquals("Authentication failed", json.readTree(response.body()).at("/error/message").asText()); + assertFalse(response.body().contains("proxy-a")); + assertFalse(response.body().contains("credential")); + } + private void assertError(HttpResponse response, int status, String code) throws Exception { - assertEquals(status, response.statusCode()); assertEquals(code, json.readTree(response.body()).at("/error/code").asText()); + assertEquals(status, response.statusCode(), response.body()); + assertEquals(code, json.readTree(response.body()).at("/error/code").asText(), response.body()); } } From 2a7b60b8f4fa0536043fa765e1ce9eb49d560fd8 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 24 Aug 2026 18:47:31 -0600 Subject: [PATCH 03/42] Fix identity publication and CLI path selection --- .../control/ControlApplication.java | 50 +++++++++++++------ .../control/ControlApplicationTest.java | 15 ++++++ 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java index 89bc239e..9cc7ef02 100644 --- a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java +++ b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java @@ -6,9 +6,11 @@ import com.bencodez.votingplugin.control.protocol.ControlIdentity; import com.bencodez.votingplugin.control.protocol.Protocol; import java.io.IOException; +import java.io.PrintStream; import java.net.InetAddress; import java.net.InetSocketAddress; -import java.nio.file.FileAlreadyExistsException; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; @@ -19,6 +21,8 @@ import java.util.concurrent.CountDownLatch; public final class ControlApplication { + private static final Object IDENTITY_LOCK = new Object(); + private ControlApplication() { } public static void main(String[] args) { @@ -39,34 +43,42 @@ public static void main(String[] args) { } private static void runOwnerCommand(String[] args) throws IOException { - Path dataDirectory = args.length >= 3 ? requirePath(args[2]) - : requirePath(System.getenv().getOrDefault("CONTROL_DATA_DIR", "data")); - CredentialStore credentials = new CredentialStore(dataDirectory); + runOwnerCommand(args, System.getenv(), System.out); + } + + static void runOwnerCommand(String[] args, Map environment, PrintStream output) + throws IOException { switch (args[0]) { case "enroll" -> { if (args.length < 2 || args.length > 3) { throw new IllegalArgumentException("Usage: enroll [dataDirectory]"); } - System.out.println(credentials.rotateNode(args[1])); + Path selected = ownerDataDirectory(args.length == 3 ? args[2] : null, environment); + output.println(new CredentialStore(selected).rotateNode(args[1])); } case "revoke" -> { if (args.length < 2 || args.length > 3) { throw new IllegalArgumentException("Usage: revoke [dataDirectory]"); } - credentials.revokeNode(args[1]); - System.out.println("Enrollment revoked."); + Path selected = ownerDataDirectory(args.length == 3 ? args[2] : null, environment); + new CredentialStore(selected).revokeNode(args[1]); + output.println("Enrollment revoked."); } case "admin-token" -> { if (args.length > 2) { throw new IllegalArgumentException("Usage: admin-token [dataDirectory]"); } - Path selected = args.length == 2 ? requirePath(args[1]) : dataDirectory; - System.out.println(new CredentialStore(selected).rotateAdmin()); + Path selected = ownerDataDirectory(args.length == 2 ? args[1] : null, environment); + output.println(new CredentialStore(selected).rotateAdmin()); } default -> throw new IllegalArgumentException("Unknown command"); } } + private static Path ownerDataDirectory(String explicit, Map environment) { + return requirePath(explicit != null ? explicit : environment.getOrDefault("CONTROL_DATA_DIR", "data")); + } + static void runServer(Map environment) throws Exception { Configuration configuration = Configuration.from(environment); CredentialStore credentials = new CredentialStore(configuration.dataDirectory()); @@ -97,16 +109,22 @@ static void runServer(Map environment) throws Exception { } } - /** Create-if-absent publication cannot replace a concurrent startup's winning identity. */ + /** A process lock keeps the first identity private until its complete contents are readable. */ static UUID loadIdentity(Path directory) throws IOException { Files.createDirectories(directory); Path file = directory.resolve("instance-id"); - UUID candidate = UUID.randomUUID(); - try { - Files.writeString(file, candidate.toString(), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); - return candidate; - } catch (FileAlreadyExistsException e) { - return readIdentity(file); + Path lockFile = directory.resolve("instance-id.lock"); + synchronized (IDENTITY_LOCK) { + try (FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE); + FileLock ignored = channel.lock()) { + if (Files.exists(file)) { + return readIdentity(file); + } + UUID candidate = UUID.randomUUID(); + Files.writeString(file, candidate.toString(), StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE); + return candidate; + } } } diff --git a/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java index a98c60d6..15c5e677 100644 --- a/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java @@ -1,5 +1,7 @@ package com.bencodez.votingplugin.control; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -52,6 +54,19 @@ class ControlApplicationTest { assertThrows(java.io.IOException.class, () -> ControlApplication.loadIdentity(directory)); } + @Test void explicitAdminDirectoryDoesNotTouchConfiguredDefault() throws Exception { + Path configuredDefault = directory.resolve("unwritable-default"); + Path explicit = directory.resolve("explicit"); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + ControlApplication.runOwnerCommand(new String[]{"admin-token", explicit.toString()}, + java.util.Map.of("CONTROL_DATA_DIR", configuredDefault.toString()), new PrintStream(output)); + + assertFalse(Files.exists(configuredDefault)); + assertTrue(Files.exists(explicit.resolve("credentials.json"))); + assertTrue(output.toString().trim().startsWith("vpctl_admin_")); + } + @Test void startupConfigurationValidatesAllBounds() { assertThrows(IllegalArgumentException.class, () -> ControlApplication.Configuration.from(java.util.Map.of("CONTROL_PORT", "70000"))); From 4b4d001eb29c8cd74eade13c0a9ddb925af992e1 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 24 Aug 2026 19:31:59 -0600 Subject: [PATCH 04/42] Add minimal Control WebUI and release assets --- .github/workflows/release.yml | 37 ++++++++ README.md | 22 +++-- pom.xml | 3 + .../control/http/ControlHttpServer.java | 46 +++++++-- src/main/resources/web/app.css | 31 ++++++ src/main/resources/web/app.js | 94 +++++++++++++++++++ src/main/resources/web/index.html | 42 +++++++++ .../control/http/ControlHttpServerTest.java | 16 ++++ 8 files changed, 278 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 src/main/resources/web/app.css create mode 100644 src/main/resources/web/app.js create mode 100644 src/main/resources/web/index.html diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..a1de0358 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Publish release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + - name: Set release version + run: mvn -B versions:set -DnewVersion="${GITHUB_REF_NAME#v}" -DgenerateBackupPoms=false + - name: Build and verify + run: mvn -B clean verify + - name: Stage immutable release assets + run: | + cp "target/votingplugin-control-${GITHUB_REF_NAME#v}-all.jar" votingplugin-control.jar + sha256sum votingplugin-control.jar > votingplugin-control.jar.sha256 + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh release create "$GITHUB_REF_NAME" + votingplugin-control.jar votingplugin-control.jar.sha256 + --repo "$GITHUB_REPOSITORY" + --verify-tag + --generate-notes diff --git a/README.md b/README.md index dcb90d88..ec4b084c 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,15 @@ VotingPlugin Control is a separate, local-first administration service for a VotingPlugin network. The current milestone provides authenticated, read-only discovery of multiple BungeeCord and Velocity proxies and the backend servers each -proxy observes. It does not process votes, and VotingPlugin does not depend on it for startup, joins, routing, or shutdown. +proxy observes. It includes a minimal local WebUI over the same versioned API. It does not process votes, and VotingPlugin +does not depend on it for startup, joins, routing, or shutdown. ## Trust and deployment boundary One Control process can observe an entire network: ```text -Browser (future local UI) -> Control <- Proxy A / Proxy B / Proxy C <- backend servers +Browser -> Control WebUI/API <- Proxy A / Proxy B / Proxy C <- backend servers ``` Proxy connectors initiate outbound HTTP(S) requests. Bukkit backend nodes do not connect directly. Control works without @@ -43,8 +44,13 @@ report `development` rather than maintaining a second version literal. | `CONTROL_REQUEST_TIMEOUT_SECONDS` | `10` | `1`–`60`; bounds stalled JDK HTTP exchanges | The server also uses a bounded HTTP executor (8 active requests and a 32-request queue), a 64 KiB request limit, bounded -JSON depth/string/number sizes, and a global authentication-failure limit. Shutdown stops the server and its daemon request -workers without waiting indefinitely. +JSON depth/string/number sizes, and a bounded invalid-authentication failure limit. Valid enrolled/admin credentials remain +usable even while invalid traffic is throttled. Shutdown stops the server and its daemon request workers without waiting +indefinitely. + +Open `http://127.0.0.1:8080/` for the local WebUI. Its static shell is public on the Control listener, but topology reads +still require the admin bearer token. The token remains only in page memory and is not persisted by the UI. Static assets +use a restrictive Content Security Policy and the browser calls the same `/api/v1` endpoints documented below. ## Enrollment @@ -83,6 +89,7 @@ All errors have the stable form: | Method | Endpoint | Authentication | Behavior | | --- | --- | --- | --- | +| `GET` | `/`, `/app.js`, `/app.css` | none | Minimal local WebUI static assets | | `GET` | `/api/v1/health` | none | Health plus Control identity/application/protocol version | | `POST` | `/api/v1/nodes/register` | node | Idempotently create/replace one enrolled proxy session | | `PUT` | `/api/v1/nodes/{nodeId}/heartbeat` | matching node | Refresh liveness and replace capability advertisement | @@ -117,6 +124,7 @@ They are not misleadingly used by these simple HTTP resource endpoints. ## Intentionally out of scope -This milestone has no configuration reads or writes, WebUI, topology persistence/history, audit log, diagnostics bundle, -automatic distribution, cloud relay, or remote-support sessions. Node registry state is currently in memory, so proxies -automatically re-register after a Control restart. Manual installation remains supported. +This milestone has no configuration reads or writes, topology persistence/history, audit log, diagnostics bundle, cloud +relay, or remote-support sessions. Node registry state is currently in memory, so proxies automatically re-register after +a Control restart. Manual installation remains supported; the companion VotingPlugin development PR can also opt in to +verified download and child-process hosting. diff --git a/pom.xml b/pom.xml index d2b6c811..00221b4b 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,9 @@ org.apache.maven.pluginsmaven-surefire-plugin3.5.2 + + org.codehaus.mojoversions-maven-plugin2.18.0 + org.apache.maven.pluginsmaven-jar-plugin3.4.2 com.bencodez.votingplugin.control.ControlApplicationtrue diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 5a9da838..021524ef 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -45,6 +45,11 @@ public final class ControlHttpServer implements AutoCloseable { private static final String HEALTH = "/api/v1/health"; private static final String NODES = "/api/v1/nodes"; private static final String REGISTER = "/api/v1/nodes/register"; + private static final Map WEB_RESOURCES = Map.of( + "/", new WebResource("/web/index.html", "text/html; charset=utf-8"), + "/index.html", new WebResource("/web/index.html", "text/html; charset=utf-8"), + "/app.js", new WebResource("/web/app.js", "text/javascript; charset=utf-8"), + "/app.css", new WebResource("/web/app.css", "text/css; charset=utf-8")); private static final int MAX_AUTH_FAILURES_PER_MINUTE = 100; private final HttpServer server; @@ -139,6 +144,12 @@ private void handle(HttpExchange exchange) throws IOException { private void route(HttpExchange exchange) throws IOException { URI uri = exchange.getRequestURI(); String path = uri.getPath(); + WebResource resource = WEB_RESOURCES.get(path); + if (resource != null) { + requireMethod(exchange, "GET"); + sendResource(exchange, resource); + return; + } if (HEALTH.equals(path)) { requireMethod(exchange, "GET"); send(exchange, 200, Map.of("status", "ok", "time", Instant.now(), "identity", identity)); @@ -243,14 +254,15 @@ private void authenticateAdmin(HttpExchange exchange) { } private void authenticate(HttpExchange exchange, java.util.function.Predicate verifier) { + String token = bearer(exchange); + if (verifier.test(token)) { + return; + } if (!authLimiter.allowAttempt()) { throw new AuthenticationException(true); } - String token = bearer(exchange); - if (!verifier.test(token)) { - authLimiter.recordFailure(); - throw new AuthenticationException(false); - } + authLimiter.recordFailure(); + throw new AuthenticationException(false); } private static String bearer(HttpExchange exchange) { @@ -274,9 +286,29 @@ private void requireMethod(HttpExchange exchange, String expected) throws IOExce private void send(HttpExchange exchange, int status, Object value) throws IOException { byte[] body = json.writeValueAsBytes(value); - exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + sendBytes(exchange, status, "application/json; charset=utf-8", body); + } + + private void sendResource(HttpExchange exchange, WebResource resource) throws IOException { + byte[] body; + try (InputStream input = ControlHttpServer.class.getResourceAsStream(resource.classpath())) { + if (input == null) { + throw new IOException("Web resource is unavailable"); + } + body = input.readAllBytes(); + } + sendBytes(exchange, 200, resource.contentType(), body); + } + + private void sendBytes(HttpExchange exchange, int status, String contentType, byte[] body) throws IOException { + exchange.getResponseHeaders().set("Content-Type", contentType); exchange.getResponseHeaders().set("Cache-Control", "no-store"); exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff"); + exchange.getResponseHeaders().set("Content-Security-Policy", + "default-src 'self'; connect-src 'self'; img-src 'self'; object-src 'none'; " + + "base-uri 'none'; frame-ancestors 'none'; form-action 'none'"); + exchange.getResponseHeaders().set("Referrer-Policy", "no-referrer"); + exchange.getResponseHeaders().set("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); exchange.sendResponseHeaders(status, body.length); try (OutputStream output = exchange.getResponseBody()) { output.write(body); @@ -345,6 +377,8 @@ private AuthenticationException(boolean rateLimited) { @SuppressWarnings("serial") private static final class ResponseCompleteException extends RuntimeException { } + private record WebResource(String classpath, String contentType) { } + static final class AuthFailureLimiter { private final java.util.function.LongSupplier nanoTime; private final int maximum; diff --git a/src/main/resources/web/app.css b/src/main/resources/web/app.css new file mode 100644 index 00000000..38b0fea4 --- /dev/null +++ b/src/main/resources/web/app.css @@ -0,0 +1,31 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + background: #08111f; + color: #e7eef9; +} + +* { box-sizing: border-box; } +body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top, #163158 0, #08111f 45%); } +main { width: min(1040px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0 80px; } +header, .section-title, .auth-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +h1, h2, h3, p { margin-top: 0; } +h1 { margin-bottom: 0; font-size: clamp(2rem, 5vw, 3.5rem); } +.eyebrow { margin-bottom: 6px; color: #72b7ff; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } +.status { border: 1px solid #38506f; border-radius: 999px; padding: 9px 14px; color: #aabbd0; } +.status.online { color: #8ef0b1; border-color: #28734b; background: #0c2c20; } +.card { margin-top: 24px; padding: 24px; border: 1px solid #263b57; border-radius: 18px; background: rgba(10, 25, 45, .88); box-shadow: 0 18px 60px rgba(0, 0, 0, .22); } +label { display: block; margin-bottom: 8px; font-weight: 700; } +input { flex: 1; min-width: 0; padding: 12px 14px; border: 1px solid #38506f; border-radius: 10px; background: #07101d; color: inherit; } +button { padding: 12px 16px; border: 0; border-radius: 10px; background: #3187ee; color: white; font-weight: 700; cursor: pointer; } +button:disabled { opacity: .5; cursor: not-allowed; } +#message { min-height: 1.5em; margin: 14px 0 0; color: #aabbd0; } +.nodes { display: grid; grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); gap: 16px; } +.nodes.empty { display: block; padding: 28px; border: 1px dashed #38506f; border-radius: 12px; color: #aabbd0; text-align: center; } +.node { padding: 18px; border: 1px solid #38506f; border-radius: 12px; background: #0b192b; } +.node h3 { margin-bottom: 6px; } +.node p { color: #aabbd0; } +ul { margin: 0; padding: 0; list-style: none; } +li { display: flex; flex-direction: column; gap: 3px; padding: 10px 0; border-top: 1px solid #263b57; } +li span { color: #aabbd0; font-size: .9rem; } +@media (max-width: 640px) { header, .auth-row { align-items: stretch; flex-direction: column; } .status { align-self: flex-start; } } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js new file mode 100644 index 00000000..f1db1018 --- /dev/null +++ b/src/main/resources/web/app.js @@ -0,0 +1,94 @@ +'use strict'; + +const health = document.querySelector('#health'); +const form = document.querySelector('#auth-form'); +const tokenInput = document.querySelector('#token'); +const message = document.querySelector('#message'); +const nodes = document.querySelector('#nodes'); +const refresh = document.querySelector('#refresh'); +let adminToken = ''; + +function text(element, value) { + element.textContent = value; + return element; +} + +async function loadHealth() { + try { + const response = await fetch('/api/v1/health', {cache: 'no-store'}); + if (!response.ok) throw new Error('health'); + const body = await response.json(); + text(health, `Online · ${body.identity.applicationVersion} · protocol ${body.identity.protocolVersion}`); + health.classList.add('online'); + } catch (_) { + text(health, 'Unavailable'); + health.classList.remove('online'); + } +} + +function backendCard(backend) { + const item = document.createElement('li'); + const state = backend.presenceKnown ? (backend.available ? 'online' : 'offline') : 'unknown'; + const title = text(document.createElement('strong'), backend.displayName); + const details = text(document.createElement('span'), + `${state} · ${backend.presenceKnown ? backend.playerCount : '–'} players · ${backend.backendId}`); + item.append(title, details); + return item; +} + +function nodeCard(node) { + const article = document.createElement('article'); + article.className = 'node'; + const title = text(document.createElement('h3'), node.displayName); + const meta = text(document.createElement('p'), + `${node.platform} · VotingPlugin ${node.pluginVersion} · ${node.online ? 'online' : 'offline'}`); + const list = document.createElement('ul'); + const backends = Array.isArray(node.backends) ? node.backends : []; + if (backends.length === 0) { + list.append(text(document.createElement('li'), 'No backends reported.')); + } else { + backends.forEach(backend => list.append(backendCard(backend))); + } + article.append(title, meta, list); + return article; +} + +async function loadNodes() { + if (!adminToken) return; + refresh.disabled = true; + text(message, 'Loading…'); + try { + const response = await fetch('/api/v1/nodes?offset=0&limit=100', { + cache: 'no-store', + headers: {'Authorization': `Bearer ${adminToken}`} + }); + if (!response.ok) { + throw new Error(response.status === 401 || response.status === 429 ? 'Authentication failed.' : 'Control request failed.'); + } + const body = await response.json(); + nodes.replaceChildren(); + nodes.classList.toggle('empty', body.items.length === 0); + if (body.items.length === 0) { + text(nodes, 'No proxies have registered yet.'); + } else { + body.items.forEach(node => nodes.append(nodeCard(node))); + } + text(message, `${body.items.length} prox${body.items.length === 1 ? 'y' : 'ies'} loaded.`); + } catch (error) { + nodes.replaceChildren(); + nodes.classList.add('empty'); + text(nodes, 'Network data is unavailable.'); + text(message, error.message || 'Control request failed.'); + } finally { + refresh.disabled = false; + } +} + +form.addEventListener('submit', event => { + event.preventDefault(); + adminToken = tokenInput.value.trim(); + tokenInput.value = ''; + loadNodes(); +}); +refresh.addEventListener('click', loadNodes); +loadHealth(); diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html new file mode 100644 index 00000000..d916cbd9 --- /dev/null +++ b/src/main/resources/web/index.html @@ -0,0 +1,42 @@ + + + + + + VotingPlugin Control + + + +
+
+
+

Local network console

+

VotingPlugin Control

+
+ Connecting… +
+ +
+

Network discovery

+

Enter the local admin token to load enrolled proxies and their observed backend servers. The token stays in this page's memory and is never saved.

+
+ +
+ + +
+
+

+
+ +
+
+

Proxies and backends

+ +
+
Authenticate to view the network.
+
+
+ + + diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 5c90729b..d05553f3 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -53,6 +53,13 @@ class ControlHttpServerTest { } @Test void healthRouteIsExactUnknownRoutesAreStructuredAndMethodsAreIntentional() throws Exception { + HttpResponse web = get("/", null); + assertEquals(200, web.statusCode()); + assertTrue(web.body().contains("VotingPlugin Control")); + assertTrue(web.headers().firstValue("Content-Security-Policy").orElseThrow().contains("default-src 'self'")); + assertEquals(200, get("/app.js", null).statusCode()); + assertEquals(200, get("/app.css", null).statusCode()); + assertError(send("POST", "/", null, null), 405, "METHOD_NOT_ALLOWED"); assertEquals(200, get("/api/v1/health", null).statusCode()); assertError(get("/api/v1/health/anything", null), 404, "NOT_FOUND"); assertError(get("/api/v1/nodes/register/anything", null), 404, "NOT_FOUND"); @@ -96,6 +103,15 @@ class ControlHttpServerTest { assertEquals(200, get("/api/v1/nodes", adminToken).statusCode()); } + @Test void validCredentialsBypassSaturatedFailureLimiter() throws Exception { + for (int i = 0; i < 100; i++) { + assertAuthFailure(get("/api/v1/nodes", "wrong-" + i)); + } + assertError(get("/api/v1/nodes", "one-too-many"), 429, "AUTH_RATE_LIMITED"); + assertEquals(200, get("/api/v1/nodes", adminToken).statusCode()); + assertEquals(201, send("POST", "/api/v1/nodes/register", registration(), nodeToken).statusCode()); + } + @Test void malformedEmptyNullDuplicateNestedOversizedAndLongPayloadsAreDeterministic() throws Exception { assertError(send("POST", "/api/v1/nodes/register", "{", nodeToken), 400, "MALFORMED_JSON"); assertError(send("POST", "/api/v1/nodes/register", "", nodeToken), 400, "MALFORMED_JSON"); From 693f7d5e116643611652e7549c7b398a5c728d62 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 24 Aug 2026 19:34:07 -0600 Subject: [PATCH 05/42] Make oversized request test transport deterministic --- .../control/http/ControlHttpServerTest.java | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index d05553f3..ab7c4d78 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.net.InetSocketAddress; +import java.net.Socket; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -120,8 +121,13 @@ class ControlHttpServerTest { assertError(send("POST", "/api/v1/nodes/register", duplicate, nodeToken), 400, "MALFORMED_JSON"); String nested = "[".repeat(25) + "0" + "]".repeat(25); assertError(send("POST", "/api/v1/nodes/register", nested, nodeToken), 400, "MALFORMED_JSON"); - assertError(send("POST", "/api/v1/nodes/register", " ".repeat(ControlHttpServer.MAX_REQUEST_BYTES + 1), - nodeToken), 413, "REQUEST_TOO_LARGE"); + String oversized = sendRaw("POST /api/v1/nodes/register HTTP/1.1\r\n" + + "Host: 127.0.0.1\r\nContent-Type: application/json\r\n" + + "Authorization: Bearer " + nodeToken + "\r\n" + + "Content-Length: " + (ControlHttpServer.MAX_REQUEST_BYTES + 1) + "\r\n" + + "Connection: close\r\n\r\n"); + assertTrue(oversized.startsWith("HTTP/1.1 413"), oversized); + assertTrue(oversized.contains("REQUEST_TOO_LARGE"), oversized); assertError(send("POST", "/api/v1/nodes/register", registration().replace("Proxy A", "x".repeat(101)), nodeToken), 400, "VALIDATION_ERROR"); HttpRequest invalidUtf8 = HttpRequest.newBuilder(base.resolve("/api/v1/nodes/register")) @@ -174,6 +180,16 @@ private HttpResponse send(String method, String path, String body, Strin return client.send(builder.build(), HttpResponse.BodyHandlers.ofString()); } + private String sendRaw(String request) throws Exception { + try (Socket socket = new Socket("127.0.0.1", server.port())) { + socket.setSoTimeout(3000); + socket.getOutputStream().write(request.getBytes(StandardCharsets.US_ASCII)); + socket.getOutputStream().flush(); + socket.shutdownOutput(); + return new String(socket.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + } + } + private void assertAuthFailure(HttpResponse response) throws Exception { assertError(response, 401, "UNAUTHORIZED"); assertEquals("Authentication failed", json.readTree(response.body()).at("/error/message").asText()); From 794214f54c1ab26a17f7c9340d1d7632fe2eb9d5 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 24 Aug 2026 19:40:13 -0600 Subject: [PATCH 06/42] Paginate WebUI proxy discovery --- src/main/resources/web/app.css | 2 ++ src/main/resources/web/app.js | 27 ++++++++++++++++--- src/main/resources/web/index.html | 5 ++++ .../control/http/ControlHttpServerTest.java | 5 +++- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/main/resources/web/app.css b/src/main/resources/web/app.css index 38b0fea4..8a93ad37 100644 --- a/src/main/resources/web/app.css +++ b/src/main/resources/web/app.css @@ -25,6 +25,8 @@ button:disabled { opacity: .5; cursor: not-allowed; } .node { padding: 18px; border: 1px solid #38506f; border-radius: 12px; background: #0b192b; } .node h3 { margin-bottom: 6px; } .node p { color: #aabbd0; } +.pagination { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 20px; } +.pagination output { min-width: 80px; color: #aabbd0; text-align: center; } ul { margin: 0; padding: 0; list-style: none; } li { display: flex; flex-direction: column; gap: 3px; padding: 10px 0; border-top: 1px solid #263b57; } li span { color: #aabbd0; font-size: .9rem; } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index f1db1018..5c8e2e76 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -6,7 +6,12 @@ const tokenInput = document.querySelector('#token'); const message = document.querySelector('#message'); const nodes = document.querySelector('#nodes'); const refresh = document.querySelector('#refresh'); +const previousPage = document.querySelector('#previous-page'); +const nextPage = document.querySelector('#next-page'); +const pageNumber = document.querySelector('#page-number'); +const PAGE_SIZE = 100; let adminToken = ''; +let pageOffset = 0; function text(element, value) { element.textContent = value; @@ -56,9 +61,11 @@ function nodeCard(node) { async function loadNodes() { if (!adminToken) return; refresh.disabled = true; + previousPage.disabled = true; + nextPage.disabled = true; text(message, 'Loading…'); try { - const response = await fetch('/api/v1/nodes?offset=0&limit=100', { + const response = await fetch(`/api/v1/nodes?offset=${pageOffset}&limit=${PAGE_SIZE}`, { cache: 'no-store', headers: {'Authorization': `Bearer ${adminToken}`} }); @@ -69,11 +76,16 @@ async function loadNodes() { nodes.replaceChildren(); nodes.classList.toggle('empty', body.items.length === 0); if (body.items.length === 0) { - text(nodes, 'No proxies have registered yet.'); + text(nodes, pageOffset === 0 ? 'No proxies have registered yet.' : 'No proxies on this page.'); } else { body.items.forEach(node => nodes.append(nodeCard(node))); } - text(message, `${body.items.length} prox${body.items.length === 1 ? 'y' : 'ies'} loaded.`); + const first = body.items.length === 0 ? 0 : pageOffset + 1; + const last = pageOffset + body.items.length; + text(message, body.items.length === 0 ? 'No proxies on this page.' : `Showing proxies ${first}–${last}.`); + text(pageNumber, `Page ${Math.floor(pageOffset / PAGE_SIZE) + 1}`); + previousPage.disabled = pageOffset === 0; + nextPage.disabled = body.items.length < PAGE_SIZE; } catch (error) { nodes.replaceChildren(); nodes.classList.add('empty'); @@ -88,7 +100,16 @@ form.addEventListener('submit', event => { event.preventDefault(); adminToken = tokenInput.value.trim(); tokenInput.value = ''; + pageOffset = 0; loadNodes(); }); refresh.addEventListener('click', loadNodes); +previousPage.addEventListener('click', () => { + pageOffset = Math.max(0, pageOffset - PAGE_SIZE); + loadNodes(); +}); +nextPage.addEventListener('click', () => { + pageOffset += PAGE_SIZE; + loadNodes(); +}); loadHealth(); diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index d916cbd9..6b68b0af 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -35,6 +35,11 @@

Proxies and backends

Authenticate to view the network.
+ diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index ab7c4d78..d366ce79 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -58,7 +58,10 @@ class ControlHttpServerTest { assertEquals(200, web.statusCode()); assertTrue(web.body().contains("VotingPlugin Control")); assertTrue(web.headers().firstValue("Content-Security-Policy").orElseThrow().contains("default-src 'self'")); - assertEquals(200, get("/app.js", null).statusCode()); + HttpResponse script = get("/app.js", null); + assertEquals(200, script.statusCode()); + assertTrue(script.body().contains("offset=${pageOffset}&limit=${PAGE_SIZE}")); + assertTrue(script.body().contains("nextPage.addEventListener")); assertEquals(200, get("/app.css", null).statusCode()); assertError(send("POST", "/", null, null), 405, "METHOD_NOT_ALLOWED"); assertEquals(200, get("/api/v1/health", null).statusCode()); From 6b7402f529b2af9674173d9fadc9b378d444e597 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 24 Aug 2026 20:10:15 -0600 Subject: [PATCH 07/42] Add approved proxy routing configuration control --- README.md | 36 ++- .../control/ControlApplication.java | 10 +- .../control/domain/ConfigurationAuditLog.java | 96 ++++++++ .../domain/ConfigurationOperations.java | 227 ++++++++++++++++++ .../control/domain/InMemoryNodeRegistry.java | 18 +- .../control/domain/NodeRegistry.java | 2 + .../control/http/ControlHttpServer.java | 90 ++++++- .../protocol/ConfigurationRequests.java | 20 ++ .../control/protocol/ConfigurationTask.java | 6 + .../protocol/ConfigurationTaskResult.java | 12 + .../protocol/ProxyRoutingConfiguration.java | 23 ++ src/main/resources/web/app.css | 11 +- src/main/resources/web/app.js | 125 +++++++++- src/main/resources/web/index.html | 20 ++ .../domain/ConfigurationOperationsTest.java | 65 +++++ .../control/http/ControlHttpServerTest.java | 38 +++ 16 files changed, 778 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationRequests.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTask.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTaskResult.java create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/ProxyRoutingConfiguration.java create mode 100644 src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java diff --git a/README.md b/README.md index ec4b084c..eaa91925 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # VotingPlugin Control VotingPlugin Control is a separate, local-first administration service for a VotingPlugin network. The current milestone -provides authenticated, read-only discovery of multiple BungeeCord and Velocity proxies and the backend servers each -proxy observes. It includes a minimal local WebUI over the same versioned API. It does not process votes, and VotingPlugin +provides authenticated discovery of multiple BungeeCord and Velocity proxies and a bounded proxy-routing configuration +workflow. It includes a local WebUI over the same versioned API. It does not process votes, and VotingPlugin does not depend on it for startup, joins, routing, or shutdown. ## Trust and deployment boundary @@ -48,9 +48,9 @@ JSON depth/string/number sizes, and a bounded invalid-authentication failure lim usable even while invalid traffic is throttled. Shutdown stops the server and its daemon request workers without waiting indefinitely. -Open `http://127.0.0.1:8080/` for the local WebUI. Its static shell is public on the Control listener, but topology reads -still require the admin bearer token. The token remains only in page memory and is not persisted by the UI. Static assets -use a restrictive Content Security Policy and the browser calls the same `/api/v1` endpoints documented below. +Open `http://127.0.0.1:8080/` for the local WebUI. Its static shell is public on the Control listener, but topology and +configuration operations require the admin bearer token. The token and one-time preview approval remain only in page +memory. Static assets use a restrictive Content Security Policy and the browser calls the same `/api/v1` endpoints. ## Enrollment @@ -94,7 +94,13 @@ All errors have the stable form: | `POST` | `/api/v1/nodes/register` | node | Idempotently create/replace one enrolled proxy session | | `PUT` | `/api/v1/nodes/{nodeId}/heartbeat` | matching node | Refresh liveness and replace capability advertisement | | `PUT` | `/api/v1/nodes/{nodeId}/presence` | matching node | Replace that session's backend snapshot | +| `POST` | `/api/v1/nodes/{nodeId}/operations` | matching node | Claim one queued configuration task, or `204` | +| `POST` | `/api/v1/nodes/{nodeId}/operations/{operationId}/result` | matching node | Complete that node's task | | `GET` | `/api/v1/nodes?offset=0&limit=50` | admin | Stable node-ID ordering; limit `1`–`100` | +| `POST` | `/api/v1/configuration/read` | admin | Queue a typed read for selected capable nodes | +| `POST` | `/api/v1/configuration/preview` | admin | Queue independent validation and normalized diffs | +| `POST` | `/api/v1/configuration/apply` | admin + one-time approval | Apply the exact successful preview | +| `GET` | `/api/v1/operations/{operationId}` | admin | Read overall and per-node operation status | Routes are exact. Child suffixes do not inherit a handler, every known endpoint has an intentional method/structured 405, and all unknown endpoints return a structured 404. @@ -104,6 +110,18 @@ clears its old topology. Presence snapshots are full replacements, contain at mo monotonic sequence within the registered session. Replayed or out-of-order sequence numbers are idempotently ignored. Control records its own observation time; it does not trust remote wall-clock time for online/offline decisions. +Configuration control is deliberately not a YAML editor. Protocol capability `config.proxy-routing.v1` exposes only +`sendVotesToAllServers` and `blockedServers`; credentials, database settings, plugin channels, and unknown keys never enter +the API. Preview validates each node without writing and returns its current revision and typed changes. Apply consumes a +single-use random approval token, carries each previewed revision to that node, and reports partial failures instead of a +network-wide success. Proxies create a local backup, require atomic replacement of the configuration file, soft-reload, and restore +the backup if reload fails. + +`configuration-audit.jsonl` records bounded, append-only, hash-chained operation metadata and rotates once at 5 MiB. It +does not contain configuration values, credentials, or approval tokens. Operation queues are bounded and retained in +memory for 24 hours after completion; active operations are intentionally lost on Control restart and must be previewed +again. + A node is online only while `lastSeen + offlineTimeout` is strictly after Control's current time. The exact timeout boundary is offline. Backend entries distinguish whether authoritative backend presence is known from the existing VotingPlugin presence protocol; server addresses and all unrelated configuration are excluded. @@ -124,7 +142,7 @@ They are not misleadingly used by these simple HTTP resource endpoints. ## Intentionally out of scope -This milestone has no configuration reads or writes, topology persistence/history, audit log, diagnostics bundle, cloud -relay, or remote-support sessions. Node registry state is currently in memory, so proxies automatically re-register after -a Control restart. Manual installation remains supported; the companion VotingPlugin development PR can also opt in to -verified download and child-process hosting. +This milestone has no unrestricted configuration editor, manual rollback endpoint, topology persistence/history, +diagnostics bundle, cloud relay, or remote-support sessions. Node and operation state is currently in memory, so proxies +automatically re-register after a Control restart and an interrupted change requires a new preview. Manual installation +remains supported; the companion VotingPlugin development PR can also opt in to verified download and child-process hosting. diff --git a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java index 9cc7ef02..fa36b8b7 100644 --- a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java +++ b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java @@ -2,6 +2,8 @@ import com.bencodez.votingplugin.control.auth.CredentialStore; import com.bencodez.votingplugin.control.domain.InMemoryNodeRegistry; +import com.bencodez.votingplugin.control.domain.ConfigurationAuditLog; +import com.bencodez.votingplugin.control.domain.ConfigurationOperations; import com.bencodez.votingplugin.control.http.ControlHttpServer; import com.bencodez.votingplugin.control.protocol.ControlIdentity; import com.bencodez.votingplugin.control.protocol.Protocol; @@ -90,8 +92,12 @@ static void runServer(Map environment) throws Exception { System.setProperty("sun.net.httpserver.maxRspTime", Integer.toString(configuration.requestTimeoutSeconds())); ControlIdentity identity = new ControlIdentity(loadIdentity(configuration.dataDirectory()), VersionInfo.applicationVersion(), Protocol.VERSION); - ControlHttpServer server = new ControlHttpServer(configuration.address(), - new InMemoryNodeRegistry(Clock.systemUTC(), configuration.offlineTimeout()), identity, credentials); + Clock clock = Clock.systemUTC(); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, configuration.offlineTimeout()); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(configuration.dataDirectory(), clock), clock); + ControlHttpServer server = new ControlHttpServer(configuration.address(), registry, identity, credentials, + operations); CountDownLatch shutdown = new CountDownLatch(1); Runtime.getRuntime().addShutdownHook(new Thread(() -> { server.close(); diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java new file mode 100644 index 00000000..be998839 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java @@ -0,0 +1,96 @@ +package com.bencodez.votingplugin.control.domain; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.time.Clock; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** Local append-only, hash-chained operation metadata. Values and approval tokens are never recorded. */ +public final class ConfigurationAuditLog { + private static final long MAX_BYTES = 5L * 1024 * 1024; + private final Path file; + private final Path previousFile; + private final Clock clock; + private final ObjectMapper json = new ObjectMapper(); + private String previousHash = "GENESIS"; + + public ConfigurationAuditLog(Path dataDirectory, Clock clock) throws IOException { + Files.createDirectories(dataDirectory); + this.file = dataDirectory.resolve("configuration-audit.jsonl"); + this.previousFile = dataDirectory.resolve("configuration-audit.jsonl.1"); + this.clock = clock; + if (Files.isRegularFile(file) && Files.size(file) <= MAX_BYTES) { + previousHash = validateExisting(); + } + } + + public synchronized void append(String action, UUID operationId, String nodeId, String outcome) { + try { + rotateIfNeeded(); + Map core = new LinkedHashMap<>(); + core.put("time", clock.instant().toString()); + core.put("action", action); + core.put("operationId", operationId == null ? null : operationId.toString()); + core.put("nodeId", nodeId); + core.put("outcome", outcome); + core.put("previousHash", previousHash); + byte[] canonical = json.writeValueAsBytes(core); + String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical)); + core.put("hash", hash); + Files.writeString(file, json.writeValueAsString(core) + System.lineSeparator(), StandardCharsets.UTF_8, + StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE); + previousHash = hash; + } catch (Exception e) { + throw new AuditException(e); + } + } + + private void rotateIfNeeded() throws IOException { + if (Files.exists(file) && Files.size(file) >= MAX_BYTES) { + Files.move(file, previousFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + previousHash = "GENESIS"; + } + } + + private String validateExisting() throws IOException { + String expectedPrevious = "GENESIS"; + for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { + if (line.isBlank()) continue; + try { + JsonNode stored = json.readTree(line); + Map core = new LinkedHashMap<>(); + core.put("time", stored.path("time").asText()); + core.put("action", stored.path("action").asText()); + core.put("operationId", stored.path("operationId").isNull() ? null : stored.path("operationId").asText()); + core.put("nodeId", stored.path("nodeId").isNull() ? null : stored.path("nodeId").asText()); + core.put("outcome", stored.path("outcome").isNull() ? null : stored.path("outcome").asText()); + core.put("previousHash", stored.path("previousHash").asText()); + String calculated = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(json.writeValueAsBytes(core))); + if (!expectedPrevious.equals(core.get("previousHash")) || !calculated.equals(stored.path("hash").asText())) { + throw new IOException("Configuration audit chain is invalid"); + } + expectedPrevious = calculated; + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException("Configuration audit chain is invalid", e); + } + } + return expectedPrevious; + } + + @SuppressWarnings("serial") + public static final class AuditException extends RuntimeException { + private AuditException(Throwable cause) { super(cause); } + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java new file mode 100644 index 00000000..755951c5 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -0,0 +1,227 @@ +package com.bencodez.votingplugin.control.domain; + +import com.bencodez.votingplugin.control.protocol.ConfigurationTask; +import com.bencodez.votingplugin.control.protocol.ConfigurationTaskResult; +import com.bencodez.votingplugin.control.protocol.NodeStatus; +import com.bencodez.votingplugin.control.protocol.ProxyRoutingConfiguration; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +/** In-memory, bounded coordinator for outbound node configuration tasks. */ +public final class ConfigurationOperations { + public static final String CAPABILITY = "config.proxy-routing.v1"; + private static final int MAX_OPERATIONS = 1000; + private static final Duration LEASE = Duration.ofMinutes(2); + private static final Duration RETENTION = Duration.ofHours(24); + + private final NodeRegistry registry; + private final ConfigurationAuditLog audit; + private final Clock clock; + private final SecureRandom random = new SecureRandom(); + private final LinkedHashMap operations = new LinkedHashMap<>(); + + public ConfigurationOperations(NodeRegistry registry, ConfigurationAuditLog audit, Clock clock) { + this.registry = Objects.requireNonNull(registry); + this.audit = Objects.requireNonNull(audit); + this.clock = Objects.requireNonNull(clock); + } + + public synchronized OperationView createRead(List nodeIds) { + return create("READ", validateTargets(nodeIds), null, null); + } + + public synchronized OperationView createPreview(List nodeIds, ProxyRoutingConfiguration configuration) { + if (configuration == null) throw invalid("configuration is required"); + byte[] token = new byte[32]; + random.nextBytes(token); + return create("PREVIEW", validateTargets(nodeIds), configuration, + Base64.getUrlEncoder().withoutPadding().encodeToString(token)); + } + + public synchronized OperationView createApply(UUID previewId, String approvalToken) { + prune(); + StoredOperation preview = operations.get(previewId); + if (preview == null || !"PREVIEW".equals(preview.type)) throw invalid("preview operation was not found"); + if (!preview.complete() || preview.results.values().stream().anyMatch(result -> !result.success())) { + throw new ValidationException("PREVIEW_INCOMPLETE", "Every node must pass preview before apply", List.of()); + } + if (preview.approvalUsed || approvalToken == null || preview.approvalToken == null + || !MessageDigest.isEqual(preview.approvalToken.getBytes(java.nio.charset.StandardCharsets.UTF_8), + approvalToken.getBytes(java.nio.charset.StandardCharsets.UTF_8))) { + throw new ValidationException("APPROVAL_REQUIRED", "A valid unused preview approval is required", List.of()); + } + preview.approvalUsed = true; + Map revisions = new LinkedHashMap<>(); + preview.results.forEach((node, result) -> revisions.put(node, result.revision())); + StoredOperation apply = store("APPLY", new ArrayList<>(preview.states.keySet()), preview.configuration, + null, revisions); + try { + audit.append("APPLY_APPROVED", apply.id, null, "QUEUED"); + } catch (RuntimeException e) { + operations.remove(apply.id); + preview.approvalUsed = false; + throw e; + } + return view(apply); + } + + public synchronized OperationView get(UUID id) { + prune(); + StoredOperation operation = operations.get(id); + if (operation == null) throw new ValidationException("OPERATION_NOT_FOUND", "Operation was not found", List.of()); + return view(operation); + } + + public synchronized ConfigurationTask claim(String nodeId) { + prune(); + Instant now = clock.instant(); + for (StoredOperation operation : operations.values()) { + String state = operation.states.get(nodeId); + Instant leased = operation.leasedAt.get(nodeId); + if ("QUEUED".equals(state) || ("IN_PROGRESS".equals(state) && leased != null + && !now.isBefore(leased.plus(LEASE)))) { + operation.states.put(nodeId, "IN_PROGRESS"); + operation.leasedAt.put(nodeId, now); + try { + audit.append("TASK_CLAIMED", operation.id, nodeId, operation.type); + } catch (RuntimeException e) { + operation.states.put(nodeId, state); + if (leased == null) operation.leasedAt.remove(nodeId); else operation.leasedAt.put(nodeId, leased); + throw e; + } + return new ConfigurationTask(operation.id, operation.type, operation.configuration, + operation.expectedRevisions.get(nodeId)); + } + } + return null; + } + + public synchronized OperationView complete(UUID operationId, String nodeId, ConfigurationTaskResult result) { + StoredOperation operation = operations.get(operationId); + if (operation == null || !operation.states.containsKey(nodeId)) { + throw new ValidationException("OPERATION_NOT_FOUND", "Operation task was not found", List.of()); + } + validateResult(result); + if ("COMPLETE".equals(operation.states.get(nodeId))) return view(operation); + if (!"IN_PROGRESS".equals(operation.states.get(nodeId))) { + throw new ValidationException("TASK_NOT_CLAIMED", "Operation task must be claimed before completion", List.of()); + } + audit.append("TASK_COMPLETED", operation.id, nodeId, result.success() ? "SUCCESS" : result.code()); + operation.results.put(nodeId, result); + operation.states.put(nodeId, "COMPLETE"); + operation.leasedAt.remove(nodeId); + return view(operation); + } + + private OperationView create(String type, List targets, ProxyRoutingConfiguration config, String token) { + StoredOperation operation = store(type, targets, config, token, Map.of()); + try { + audit.append("OPERATION_CREATED", operation.id, null, type); + } catch (RuntimeException e) { + operations.remove(operation.id); + throw e; + } + return view(operation); + } + + private StoredOperation store(String type, List targets, ProxyRoutingConfiguration config, + String token, Map revisions) { + prune(); + if (operations.size() >= MAX_OPERATIONS) throw new ValidationException("OPERATION_LIMIT", + "Too many retained operations", List.of()); + UUID id = UUID.randomUUID(); + LinkedHashMap states = new LinkedHashMap<>(); + targets.forEach(node -> states.put(node, "QUEUED")); + StoredOperation result = new StoredOperation(id, type, config, token, clock.instant(), states, + new LinkedHashMap<>(), new LinkedHashMap<>(), new LinkedHashMap<>(revisions)); + operations.put(id, result); + return result; + } + + private List validateTargets(List nodeIds) { + if (nodeIds == null || nodeIds.isEmpty() || nodeIds.size() > 100) throw invalid("nodeIds must contain 1 to 100 entries"); + Set unique = new HashSet<>(); + List result = new ArrayList<>(); + for (String nodeId : nodeIds) { + if (nodeId == null || !unique.add(nodeId)) throw invalid("nodeIds must be unique"); + NodeStatus node = registry.find(nodeId); + if (node == null || !node.online() || !node.acceptedCapabilities().contains(CAPABILITY)) { + throw new ValidationException("NODE_UNAVAILABLE", "Node cannot accept configuration operations", List.of(nodeId)); + } + result.add(nodeId); + } + return List.copyOf(result); + } + + private OperationView view(StoredOperation operation) { + String state = operation.complete() ? (operation.results.values().stream().allMatch(ConfigurationTaskResult::success) + ? "SUCCEEDED" : "COMPLETED_WITH_ERRORS") : "RUNNING"; + String approval = "PREVIEW".equals(operation.type) && operation.complete() + && operation.results.values().stream().allMatch(ConfigurationTaskResult::success) + && !operation.approvalUsed ? operation.approvalToken : null; + return new OperationView(operation.id, operation.type, state, operation.createdAt, operation.configuration, + Map.copyOf(operation.states), Map.copyOf(operation.results), approval); + } + + private void prune() { + Instant cutoff = clock.instant().minus(RETENTION); + operations.entrySet().removeIf(entry -> entry.getValue().complete() && entry.getValue().createdAt.isBefore(cutoff)); + } + + private static ValidationException invalid(String detail) { + return new ValidationException("VALIDATION_ERROR", "Request validation failed", List.of(detail)); + } + + private static void validateResult(ConfigurationTaskResult result) { + if (result == null || result.sessionId() == null || result.code() == null + || !result.code().matches("[A-Z][A-Z0-9_]{0,63}") || result.message() == null + || result.message().isBlank() || result.message().length() > 500 || result.changes().size() > 20 + || result.changes().stream().anyMatch(value -> value == null || value.length() > 500)) { + throw invalid("result is invalid"); + } + if (result.success() && (result.revision() == null || !result.revision().matches("[0-9a-f]{64}") + || result.configuration() == null)) { + throw invalid("successful result must include a typed configuration and revision"); + } + } + + public record OperationView(UUID operationId, String type, String state, Instant createdAt, + ProxyRoutingConfiguration configuration, Map nodeStates, + Map results, String approvalToken) { } + + private static final class StoredOperation { + private final UUID id; + private final String type; + private final ProxyRoutingConfiguration configuration; + private final String approvalToken; + private final Instant createdAt; + private final LinkedHashMap states; + private final LinkedHashMap results; + private final LinkedHashMap leasedAt; + private final LinkedHashMap expectedRevisions; + private boolean approvalUsed; + + private StoredOperation(UUID id, String type, ProxyRoutingConfiguration configuration, String approvalToken, + Instant createdAt, LinkedHashMap states, + LinkedHashMap results, + LinkedHashMap leasedAt, + LinkedHashMap expectedRevisions) { + this.id = id; this.type = type; this.configuration = configuration; this.approvalToken = approvalToken; + this.createdAt = createdAt; this.states = states; this.results = results; this.leasedAt = leasedAt; + this.expectedRevisions = expectedRevisions; + } + private boolean complete() { return states.values().stream().allMatch("COMPLETE"::equals); } + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java index 92407143..a043e684 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -24,7 +24,8 @@ /** Thread-safe, deterministic current topology registry. State is intentionally in memory for this milestone. */ public final class InMemoryNodeRegistry implements NodeRegistry { - public static final Set SUPPORTED_CAPABILITIES = Set.of("discovery.read", "presence.snapshot"); + public static final Set SUPPORTED_CAPABILITIES = Set.of("discovery.read", "presence.snapshot", + ConfigurationOperations.CAPABILITY); private static final Pattern ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final Pattern CAPABILITY = Pattern.compile("[a-z][a-z0-9.-]{0,63}"); private static final Set PLATFORMS = Set.of("BUNGEECORD", "VELOCITY"); @@ -128,6 +129,21 @@ public List list(int offset, int limit) { .skip(offset).limit(limit).map(this::view).toList(); } + @Override + public NodeStatus find(String nodeId) { + StoredNode node = nodes.get(nodeId); + return node == null ? null : view(node); + } + + @Override + public void requireSession(String nodeId, UUID sessionId) { + validateId(nodeId, "nodeId"); + validateSession(sessionId); + StoredNode node = nodes.get(nodeId); + if (node == null) throw new ValidationException("NODE_NOT_FOUND", "Node is not registered", List.of()); + requireSession(node, sessionId); + } + private NodeStatus view(StoredNode node) { // The exact boundary is offline: lastSeen + timeout must be strictly after now. boolean online = clock.instant().isBefore(node.lastSeen.plus(offlineTimeout)); diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java index 9c94aa36..4567c781 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java @@ -11,6 +11,8 @@ public interface NodeRegistry { NodeStatus heartbeat(String nodeId, Heartbeat heartbeat); SnapshotResult replacePresence(String nodeId, PresenceSnapshot snapshot); List list(int offset, int limit); + NodeStatus find(String nodeId); + void requireSession(String nodeId, java.util.UUID sessionId); record RegistrationResult(NodeStatus node, boolean created) { } record SnapshotResult(NodeStatus node, boolean applied) { } diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 021524ef..2a485a1e 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -2,7 +2,11 @@ import com.bencodez.votingplugin.control.auth.CredentialStore; import com.bencodez.votingplugin.control.domain.NodeRegistry; +import com.bencodez.votingplugin.control.domain.ConfigurationOperations; import com.bencodez.votingplugin.control.domain.ValidationException; +import com.bencodez.votingplugin.control.protocol.ConfigurationRequests; +import com.bencodez.votingplugin.control.protocol.ConfigurationTask; +import com.bencodez.votingplugin.control.protocol.ConfigurationTaskResult; import com.bencodez.votingplugin.control.protocol.ControlIdentity; import com.bencodez.votingplugin.control.protocol.Heartbeat; import com.bencodez.votingplugin.control.protocol.NodeRegistration; @@ -34,6 +38,7 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.UUID; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; @@ -45,6 +50,8 @@ public final class ControlHttpServer implements AutoCloseable { private static final String HEALTH = "/api/v1/health"; private static final String NODES = "/api/v1/nodes"; private static final String REGISTER = "/api/v1/nodes/register"; + private static final String CONFIGURATION = "/api/v1/configuration"; + private static final String OPERATIONS = "/api/v1/operations"; private static final Map WEB_RESOURCES = Map.of( "/", new WebResource("/web/index.html", "text/html; charset=utf-8"), "/index.html", new WebResource("/web/index.html", "text/html; charset=utf-8"), @@ -57,23 +64,36 @@ public final class ControlHttpServer implements AutoCloseable { private final NodeRegistry registry; private final ControlIdentity identity; private final CredentialStore credentials; + private final ConfigurationOperations configurationOperations; private final ThreadPoolExecutor executor; private final AuthFailureLimiter authLimiter; public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials) throws IOException { - this(address, registry, identity, credentials, Clock.systemUTC(), System::nanoTime); + this(address, registry, identity, credentials, + new ConfigurationOperations(registry, new com.bencodez.votingplugin.control.domain.ConfigurationAuditLog( + java.nio.file.Files.createTempDirectory("votingplugin-control-test-audit"), Clock.systemUTC()), + Clock.systemUTC()), Clock.systemUTC(), System::nanoTime); + } + + public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, + CredentialStore credentials, ConfigurationOperations configurationOperations) + throws IOException { + this(address, registry, identity, credentials, configurationOperations, Clock.systemUTC(), System::nanoTime); } ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, - CredentialStore credentials, Clock clock, java.util.function.LongSupplier nanoTime) throws IOException { + CredentialStore credentials, ConfigurationOperations configurationOperations, Clock clock, + java.util.function.LongSupplier nanoTime) throws IOException { this.registry = Objects.requireNonNull(registry, "registry"); this.identity = Objects.requireNonNull(identity, "identity"); this.credentials = Objects.requireNonNull(credentials, "credentials"); + this.configurationOperations = Objects.requireNonNull(configurationOperations, "configurationOperations"); Objects.requireNonNull(clock, "clock"); json = new ObjectMapper(); json.findAndRegisterModules(); json.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + json.disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT); json.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); json.getFactory().enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION.mappedFeature()); json.getFactory().setStreamReadConstraints(StreamReadConstraints.builder() @@ -119,8 +139,10 @@ private void handle(HttpExchange exchange) throws IOException { e.rateLimited ? "Too many authentication failures" : "Authentication failed", List.of()); } catch (ValidationException e) { int status = switch (e.code()) { - case "NODE_NOT_FOUND" -> 404; - case "UNSUPPORTED_PROTOCOL", "INCOMPATIBLE_CAPABILITIES", "SESSION_MISMATCH" -> 409; + case "NODE_NOT_FOUND", "OPERATION_NOT_FOUND" -> 404; + case "UNSUPPORTED_PROTOCOL", "INCOMPATIBLE_CAPABILITIES", "SESSION_MISMATCH", + "PREVIEW_INCOMPLETE", "APPROVAL_REQUIRED", "NODE_UNAVAILABLE", "OPERATION_LIMIT" -> 409; + case "TASK_NOT_CLAIMED" -> 409; case "UNSUPPORTED_MEDIA_TYPE" -> 415; default -> 400; }; @@ -178,26 +200,77 @@ private void route(HttpExchange exchange) throws IOException { Map.of("created", result.created(), "node", result.node(), "identity", identity)); return; } + if ((CONFIGURATION + "/read").equals(path)) { + requireMethod(exchange, "POST"); + authenticateAdmin(exchange); + ConfigurationRequests.Read request = read(exchange, ConfigurationRequests.Read.class); + send(exchange, 202, configurationOperations.createRead(request.nodeIds())); + return; + } + if ((CONFIGURATION + "/preview").equals(path)) { + requireMethod(exchange, "POST"); + authenticateAdmin(exchange); + ConfigurationRequests.Preview request = read(exchange, ConfigurationRequests.Preview.class); + send(exchange, 202, configurationOperations.createPreview(request.nodeIds(), request.configuration())); + return; + } + if ((CONFIGURATION + "/apply").equals(path)) { + requireMethod(exchange, "POST"); + authenticateAdmin(exchange); + ConfigurationRequests.Apply request = read(exchange, ConfigurationRequests.Apply.class); + send(exchange, 202, configurationOperations.createApply(request.previewOperationId(), request.approvalToken())); + return; + } + if (path != null && path.startsWith(OPERATIONS + "/")) { + String remainder = path.substring((OPERATIONS + "/").length()); + if (!remainder.contains("/")) { + requireMethod(exchange, "GET"); + authenticateAdmin(exchange); + send(exchange, 200, configurationOperations.get(UUID.fromString(remainder))); + return; + } + } String prefix = NODES + "/"; if (path != null && path.startsWith(prefix)) { String remainder = path.substring(prefix.length()); String[] segments = remainder.split("/", -1); - if (segments.length == 2 && ("heartbeat".equals(segments[1]) || "presence".equals(segments[1]))) { + if (segments.length == 2 && ("heartbeat".equals(segments[1]) || "presence".equals(segments[1]) + || "operations".equals(segments[1]))) { String nodeId = decodePathSegment(segments[0]); if ("heartbeat".equals(segments[1])) { requireMethod(exchange, "PUT"); authenticateNode(exchange, nodeId); send(exchange, 200, Map.of("node", registry.heartbeat(nodeId, read(exchange, Heartbeat.class)))); - } else { + } else if ("presence".equals(segments[1])) { requireMethod(exchange, "PUT"); authenticateNode(exchange, nodeId); NodeRegistry.SnapshotResult result = registry.replacePresence(nodeId, read(exchange, PresenceSnapshot.class)); send(exchange, 200, Map.of("applied", result.applied(), "node", result.node())); + } else { + requireMethod(exchange, "POST"); + authenticateNode(exchange, nodeId); + ConfigurationRequests.Claim claim = read(exchange, ConfigurationRequests.Claim.class); + registry.requireSession(nodeId, claim.sessionId()); + ConfigurationTask task = configurationOperations.claim(nodeId); + if (task == null) { + noContent(exchange); + } else { + send(exchange, 200, task); + } } return; } + if (segments.length == 4 && "operations".equals(segments[1]) && "result".equals(segments[3])) { + String nodeId = decodePathSegment(segments[0]); + requireMethod(exchange, "POST"); + authenticateNode(exchange, nodeId); + ConfigurationTaskResult result = read(exchange, ConfigurationTaskResult.class); + registry.requireSession(nodeId, result.sessionId()); + send(exchange, 200, configurationOperations.complete(UUID.fromString(segments[2]), nodeId, result)); + return; + } } error(exchange, 404, "NOT_FOUND", "Endpoint not found", List.of()); } @@ -289,6 +362,11 @@ private void send(HttpExchange exchange, int status, Object value) throws IOExce sendBytes(exchange, status, "application/json; charset=utf-8", body); } + private void noContent(HttpExchange exchange) throws IOException { + exchange.getResponseHeaders().set("Cache-Control", "no-store"); + exchange.sendResponseHeaders(204, -1); + } + private void sendResource(HttpExchange exchange, WebResource resource) throws IOException { byte[] body; try (InputStream input = ControlHttpServer.class.getResourceAsStream(resource.classpath())) { diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationRequests.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationRequests.java new file mode 100644 index 00000000..2b8774b5 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationRequests.java @@ -0,0 +1,20 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.util.List; +import java.util.UUID; + +public final class ConfigurationRequests { + private ConfigurationRequests() { } + + public record Read(List nodeIds) { + public Read { nodeIds = nodeIds == null ? List.of() : List.copyOf(nodeIds); } + } + + public record Preview(List nodeIds, ProxyRoutingConfiguration configuration) { + public Preview { nodeIds = nodeIds == null ? List.of() : List.copyOf(nodeIds); } + } + + public record Apply(UUID previewOperationId, String approvalToken) { } + + public record Claim(UUID sessionId) { } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTask.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTask.java new file mode 100644 index 00000000..c5065967 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTask.java @@ -0,0 +1,6 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.util.UUID; + +public record ConfigurationTask(UUID operationId, String type, ProxyRoutingConfiguration configuration, + String expectedRevision) { } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTaskResult.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTaskResult.java new file mode 100644 index 00000000..b3a34030 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTaskResult.java @@ -0,0 +1,12 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.util.List; +import java.util.UUID; + +public record ConfigurationTaskResult(UUID sessionId, boolean success, String code, String message, String revision, + ProxyRoutingConfiguration configuration, List changes, + boolean reloaded, boolean rolledBack) { + public ConfigurationTaskResult { + changes = changes == null ? List.of() : List.copyOf(changes); + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ProxyRoutingConfiguration.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ProxyRoutingConfiguration.java new file mode 100644 index 00000000..bd83fdcb --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ProxyRoutingConfiguration.java @@ -0,0 +1,23 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** The deliberately small, non-secret configuration domain exposed by Control. */ +public record ProxyRoutingConfiguration(boolean sendVotesToAllServers, List blockedServers) { + public ProxyRoutingConfiguration { + blockedServers = blockedServers == null ? List.of() : blockedServers.stream().map(String::trim).toList(); + if (blockedServers.size() > 256) { + throw new IllegalArgumentException("blockedServers must contain at most 256 entries"); + } + Set unique = new HashSet<>(); + for (String server : blockedServers) { + if (server.isBlank() || server.length() > 100 || !unique.add(server.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException("blockedServers must contain unique names of 1 to 100 characters"); + } + } + blockedServers = List.copyOf(blockedServers); + } +} diff --git a/src/main/resources/web/app.css b/src/main/resources/web/app.css index 8a93ad37..4ae8b6b1 100644 --- a/src/main/resources/web/app.css +++ b/src/main/resources/web/app.css @@ -16,13 +16,20 @@ h1 { margin-bottom: 0; font-size: clamp(2rem, 5vw, 3.5rem); } .status.online { color: #8ef0b1; border-color: #28734b; background: #0c2c20; } .card { margin-top: 24px; padding: 24px; border: 1px solid #263b57; border-radius: 18px; background: rgba(10, 25, 45, .88); box-shadow: 0 18px 60px rgba(0, 0, 0, .22); } label { display: block; margin-bottom: 8px; font-weight: 700; } -input { flex: 1; min-width: 0; padding: 12px 14px; border: 1px solid #38506f; border-radius: 10px; background: #07101d; color: inherit; } +input, textarea { flex: 1; min-width: 0; padding: 12px 14px; border: 1px solid #38506f; border-radius: 10px; background: #07101d; color: inherit; } button { padding: 12px 16px; border: 0; border-radius: 10px; background: #3187ee; color: white; font-weight: 700; cursor: pointer; } button:disabled { opacity: .5; cursor: not-allowed; } #message { min-height: 1.5em; margin: 14px 0 0; color: #aabbd0; } .nodes { display: grid; grid-template-columns: repeat(auto-fit, minmax(270px, 1fr)); gap: 16px; } .nodes.empty { display: block; padding: 28px; border: 1px dashed #38506f; border-radius: 12px; color: #aabbd0; text-align: center; } .node { padding: 18px; border: 1px solid #38506f; border-radius: 12px; background: #0b192b; } +.node-select { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +.node-select input, .check-row input { flex: none; width: 18px; height: 18px; } +.check-row { display: flex; align-items: center; gap: 10px; } +label span { color: #aabbd0; font-weight: 400; } +textarea { width: 100%; resize: vertical; margin-bottom: 14px; } +.operation-actions { display: flex; flex-wrap: wrap; gap: 10px; } +#operation-status { min-height: 84px; margin: 18px 0 0; padding: 14px; overflow: auto; border: 1px solid #263b57; border-radius: 10px; background: #07101d; color: #c6d5e8; white-space: pre-wrap; } .node h3 { margin-bottom: 6px; } .node p { color: #aabbd0; } .pagination { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 20px; } @@ -30,4 +37,4 @@ button:disabled { opacity: .5; cursor: not-allowed; } ul { margin: 0; padding: 0; list-style: none; } li { display: flex; flex-direction: column; gap: 3px; padding: 10px 0; border-top: 1px solid #263b57; } li span { color: #aabbd0; font-size: .9rem; } -@media (max-width: 640px) { header, .auth-row { align-items: stretch; flex-direction: column; } .status { align-self: flex-start; } } +@media (max-width: 640px) { header, .auth-row { align-items: stretch; flex-direction: column; } .status { align-self: flex-start; } .operation-actions button { width: 100%; } } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 5c8e2e76..e630e448 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -9,9 +9,17 @@ const refresh = document.querySelector('#refresh'); const previousPage = document.querySelector('#previous-page'); const nextPage = document.querySelector('#next-page'); const pageNumber = document.querySelector('#page-number'); +const sendAll = document.querySelector('#send-all'); +const blockedServers = document.querySelector('#blocked-servers'); +const readConfiguration = document.querySelector('#read-configuration'); +const previewConfiguration = document.querySelector('#preview-configuration'); +const applyConfiguration = document.querySelector('#apply-configuration'); +const operationStatus = document.querySelector('#operation-status'); const PAGE_SIZE = 100; let adminToken = ''; let pageOffset = 0; +let selectedNodes = new Set(); +let approvedPreview = null; function text(element, value) { element.textContent = value; @@ -45,6 +53,18 @@ function nodeCard(node) { const article = document.createElement('article'); article.className = 'node'; const title = text(document.createElement('h3'), node.displayName); + const selector = document.createElement('label'); + selector.className = 'node-select'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.disabled = !node.online || !node.acceptedCapabilities.includes('config.proxy-routing.v1'); + checkbox.checked = selectedNodes.has(node.nodeId) && !checkbox.disabled; + checkbox.addEventListener('change', () => { + if (checkbox.checked) selectedNodes.add(node.nodeId); else selectedNodes.delete(node.nodeId); + approvedPreview = null; + updateConfigurationButtons(); + }); + selector.append(checkbox, title); const meta = text(document.createElement('p'), `${node.platform} · VotingPlugin ${node.pluginVersion} · ${node.online ? 'online' : 'offline'}`); const list = document.createElement('ul'); @@ -54,10 +74,67 @@ function nodeCard(node) { } else { backends.forEach(backend => list.append(backendCard(backend))); } - article.append(title, meta, list); + article.append(selector, meta, list); return article; } +function updateConfigurationButtons(busy = false) { + const ready = adminToken && selectedNodes.size > 0 && !busy; + readConfiguration.disabled = !ready; + previewConfiguration.disabled = !ready; + applyConfiguration.disabled = !ready || !approvedPreview; +} + +async function authorized(path, options = {}) { + const response = await fetch(path, { + cache: 'no-store', + ...options, + headers: {...(options.headers || {}), 'Authorization': `Bearer ${adminToken}`} + }); + const body = response.status === 204 ? null : await response.json(); + if (!response.ok) throw new Error(body?.error?.message || `Control request failed (${response.status}).`); + return body; +} + +function proposal() { + return { + sendVotesToAllServers: sendAll.checked, + blockedServers: blockedServers.value.split(/\r?\n/).map(value => value.trim()).filter(Boolean) + }; +} + +function operationSummary(operation) { + const lines = [`${operation.type} · ${operation.state} · ${operation.operationId}`]; + Object.entries(operation.nodeStates).forEach(([node, state]) => { + const result = operation.results[node]; + lines.push(`${node}: ${result ? `${result.success ? 'success' : result.code} — ${result.message}` : state}`); + if (result?.changes?.length) result.changes.forEach(change => lines.push(` ${change}`)); + if (result?.rolledBack) lines.push(' previous file restored after reload failure'); + }); + return lines.join('\n'); +} + +async function waitForOperation(operation) { + text(operationStatus, operationSummary(operation)); + while (operation.state === 'RUNNING') { + await new Promise(resolve => window.setTimeout(resolve, 1500)); + operation = await authorized(`/api/v1/operations/${operation.operationId}`); + text(operationStatus, operationSummary(operation)); + } + return operation; +} + +async function startConfigurationOperation(path, body) { + updateConfigurationButtons(true); + try { + return await waitForOperation(await authorized(path, { + method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) + })); + } finally { + updateConfigurationButtons(false); + } +} + async function loadNodes() { if (!adminToken) return; refresh.disabled = true; @@ -80,6 +157,10 @@ async function loadNodes() { } else { body.items.forEach(node => nodes.append(nodeCard(node))); } + const visibleIds = new Set(body.items.filter(node => node.online && node.acceptedCapabilities.includes('config.proxy-routing.v1')) + .map(node => node.nodeId)); + selectedNodes = new Set([...selectedNodes].filter(node => visibleIds.has(node))); + updateConfigurationButtons(); const first = body.items.length === 0 ? 0 : pageOffset + 1; const last = pageOffset + body.items.length; text(message, body.items.length === 0 ? 'No proxies on this page.' : `Showing proxies ${first}–${last}.`); @@ -103,6 +184,48 @@ form.addEventListener('submit', event => { pageOffset = 0; loadNodes(); }); + +readConfiguration.addEventListener('click', async () => { + approvedPreview = null; + try { + const operation = await startConfigurationOperation('/api/v1/configuration/read', {nodeIds: [...selectedNodes]}); + const successful = Object.values(operation.results).filter(result => result.success); + if (successful.length) { + const first = successful[0].configuration; + sendAll.checked = first.sendVotesToAllServers; + blockedServers.value = first.blockedServers.join('\n'); + } + } catch (error) { text(operationStatus, error.message); } +}); + +previewConfiguration.addEventListener('click', async () => { + approvedPreview = null; + try { + const operation = await startConfigurationOperation('/api/v1/configuration/preview', { + nodeIds: [...selectedNodes], configuration: proposal() + }); + if (operation.state === 'SUCCEEDED' && operation.approvalToken) { + approvedPreview = {operationId: operation.operationId, approvalToken: operation.approvalToken}; + updateConfigurationButtons(); + } + } catch (error) { text(operationStatus, error.message); } +}); + +applyConfiguration.addEventListener('click', async () => { + if (!approvedPreview || !window.confirm('Apply this exact preview to every selected proxy? Each node may still reject a stale revision.')) return; + const approval = approvedPreview; + approvedPreview = null; + try { + await startConfigurationOperation('/api/v1/configuration/apply', { + previewOperationId: approval.operationId, approvalToken: approval.approvalToken + }); + } catch (error) { text(operationStatus, error.message); } +}); +[sendAll, blockedServers].forEach(field => field.addEventListener('input', () => { + if (approvedPreview) text(operationStatus, 'The proposal changed. Preview it again before apply.'); + approvedPreview = null; + updateConfigurationButtons(); +})); refresh.addEventListener('click', loadNodes); previousPage.addEventListener('click', () => { pageOffset = Math.max(0, pageOffset - PAGE_SIZE); diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 6b68b0af..65ae0e51 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -41,6 +41,26 @@

Proxies and backends

+ +
+
+
+

Proxy routing configuration

+

Only the typed, non-secret routing domain is writable. Select capable proxies above, read or preview them, then explicitly approve the exact preview.

+
+
+
+ + + +
+ + + +
+
+
Select at least one online, capable proxy.
+
diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java new file mode 100644 index 00000000..0f7ebdbd --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -0,0 +1,65 @@ +package com.bencodez.votingplugin.control.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.bencodez.votingplugin.control.protocol.ConfigurationTask; +import com.bencodez.votingplugin.control.protocol.ConfigurationTaskResult; +import com.bencodez.votingplugin.control.protocol.NodeRegistration; +import com.bencodez.votingplugin.control.protocol.ProxyRoutingConfiguration; +import java.nio.file.Path; +import java.nio.file.Files; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ConfigurationOperationsTest { + @TempDir Path directory; + + @Test void previewApprovalCarriesEachNodesRevisionIntoApplyAndIsSingleUse() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + registry.register(new NodeRegistration("proxy-a", UUID.randomUUID(), "Proxy A", "VELOCITY", "test", 1, + Set.of("presence.snapshot", ConfigurationOperations.CAPABILITY), Set.of("presence.snapshot"))); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + ProxyRoutingConfiguration proposal = new ProxyRoutingConfiguration(true, List.of("lobby")); + + ConfigurationOperations.OperationView preview = operations.createPreview(List.of("proxy-a"), proposal); + ConfigurationTask previewTask = operations.claim("proxy-a"); + assertEquals("PREVIEW", previewTask.type()); + preview = operations.complete(preview.operationId(), "proxy-a", + new ConfigurationTaskResult(registry.find("proxy-a").sessionId(), true, "OK", "valid", "a".repeat(64), proposal, + List.of("blockedServers changed"), false, false)); + assertEquals("SUCCEEDED", preview.state()); + assertNotNull(preview.approvalToken()); + + UUID previewId = preview.operationId(); + String approvalToken = preview.approvalToken(); + ConfigurationOperations.OperationView apply = operations.createApply(previewId, approvalToken); + ConfigurationTask applyTask = operations.claim("proxy-a"); + assertEquals("APPLY", applyTask.type()); + assertEquals("a".repeat(64), applyTask.expectedRevision()); + assertThrows(ValidationException.class, + () -> operations.createApply(previewId, approvalToken)); + assertEquals(apply.operationId(), applyTask.operationId()); + } + + @Test void auditChainSurvivesRestartAndTamperingFailsClosed() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + ConfigurationAuditLog first = new ConfigurationAuditLog(directory, clock); + first.append("TEST", UUID.randomUUID(), "proxy-a", "OK"); + ConfigurationAuditLog reopened = new ConfigurationAuditLog(directory, clock); + reopened.append("TEST", UUID.randomUUID(), null, "OK"); + Path file = directory.resolve("configuration-audit.jsonl"); + Files.writeString(file, Files.readString(file).replaceFirst("TEST", "EDIT")); + assertThrows(java.io.IOException.class, () -> new ConfigurationAuditLog(directory, clock)); + } +} diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index d366ce79..af137f01 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -94,6 +94,42 @@ class ControlHttpServerTest { assertTrue(listed.at("/items/0/online").asBoolean()); } + @Test void configurationPreviewApprovalAndRevisionCheckedApplyAreEndToEnd() throws Exception { + String capableRegistration = registration().replace("\"presence.snapshot\"]", + "\"presence.snapshot\",\"config.proxy-routing.v1\"]"); + assertEquals(201, send("POST", "/api/v1/nodes/register", capableRegistration, nodeToken).statusCode()); + String proposal = "{\"sendVotesToAllServers\":true,\"blockedServers\":[\"lobby\"]}"; + HttpResponse queued = send("POST", "/api/v1/configuration/preview", + "{\"nodeIds\":[\"proxy-a\"],\"configuration\":" + proposal + "}", adminToken); + assertEquals(202, queued.statusCode()); + String previewId = json.readTree(queued.body()).get("operationId").asText(); + + JsonNode previewTask = json.readTree(send("POST", "/api/v1/nodes/proxy-a/operations", + "{\"sessionId\":\"" + SESSION + "\"}", nodeToken).body()); + assertEquals("PREVIEW", previewTask.get("type").asText()); + String previewResult = "{\"sessionId\":\"" + SESSION + "\",\"success\":true,\"code\":\"OK\","+ + "\"message\":\"valid\",\"revision\":\"" + "a".repeat(64) + "\",\"configuration\":" + + proposal + ",\"changes\":[\"blockedServers changed\"],\"reloaded\":false,\"rolledBack\":false}"; + assertEquals(200, send("POST", "/api/v1/nodes/proxy-a/operations/" + previewId + "/result", + previewResult, nodeToken).statusCode()); + + JsonNode preview = json.readTree(get("/api/v1/operations/" + previewId, adminToken).body()); + String approval = preview.get("approvalToken").asText(); + HttpResponse applyQueued = send("POST", "/api/v1/configuration/apply", + "{\"previewOperationId\":\"" + previewId + "\",\"approvalToken\":\"" + approval + "\"}", + adminToken); + assertEquals(202, applyQueued.statusCode()); + String applyId = json.readTree(applyQueued.body()).get("operationId").asText(); + JsonNode applyTask = json.readTree(send("POST", "/api/v1/nodes/proxy-a/operations", + "{\"sessionId\":\"" + SESSION + "\"}", nodeToken).body()); + assertEquals("APPLY", applyTask.get("type").asText()); + assertEquals("a".repeat(64), applyTask.get("expectedRevision").asText()); + assertError(send("POST", "/api/v1/configuration/apply", + "{\"previewOperationId\":\"" + previewId + "\",\"approvalToken\":\"" + approval + "\"}", + adminToken), 409, "APPROVAL_REQUIRED"); + assertEquals(applyId, applyTask.get("operationId").asText()); + } + @Test void missingInvalidWrongNodeRevokedAndRotatedCredentialsFailWithoutDisclosure() throws Exception { assertAuthFailure(send("POST", "/api/v1/nodes/register", registration(), null)); assertAuthFailure(send("POST", "/api/v1/nodes/register", registration(), "wrong")); @@ -146,6 +182,8 @@ class ControlHttpServerTest { nodeToken = credentials.rotateNode("proxy-a"); assertError(send("POST", "/api/v1/nodes/register", registration().replace("\"protocolVersion\":1", "\"protocolVersion\":2"), nodeToken), 409, "UNSUPPORTED_PROTOCOL"); + assertError(send("POST", "/api/v1/nodes/register", registration().replace("\"protocolVersion\":1", + "\"protocolVersion\":1.9"), nodeToken), 400, "MALFORMED_JSON"); assertError(send("POST", "/api/v1/nodes/register", registration().replace("\"requiredCapabilities\":[]", "\"requiredCapabilities\":[\"future.required\"]"), nodeToken), 409, "INCOMPATIBLE_CAPABILITIES"); From 5c1acf26fc01034492cd7706002f20503224d899 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 24 Aug 2026 20:13:51 -0600 Subject: [PATCH 08/42] Fail closed on oversized audit chains --- .../control/domain/ConfigurationAuditLog.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java index be998839..7497cd16 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java @@ -28,7 +28,10 @@ public ConfigurationAuditLog(Path dataDirectory, Clock clock) throws IOException this.file = dataDirectory.resolve("configuration-audit.jsonl"); this.previousFile = dataDirectory.resolve("configuration-audit.jsonl.1"); this.clock = clock; - if (Files.isRegularFile(file) && Files.size(file) <= MAX_BYTES) { + if (Files.isRegularFile(file)) { + if (Files.size(file) > MAX_BYTES + 64 * 1024) { + throw new IOException("Configuration audit log exceeds its bounded size"); + } previousHash = validateExisting(); } } @@ -67,6 +70,12 @@ private String validateExisting() throws IOException { if (line.isBlank()) continue; try { JsonNode stored = json.readTree(line); + if (stored == null || !stored.isObject() || stored.size() != 7 + || !stored.has("time") || !stored.has("action") || !stored.has("operationId") + || !stored.has("nodeId") || !stored.has("outcome") || !stored.has("previousHash") + || !stored.has("hash")) { + throw new IOException("Configuration audit chain is invalid"); + } Map core = new LinkedHashMap<>(); core.put("time", stored.path("time").asText()); core.put("action", stored.path("action").asText()); From 4bd4482c3052626f5de6666a5b3160140365a81c Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 24 Aug 2026 20:37:57 -0600 Subject: [PATCH 09/42] Add password-protected WebUI sessions --- README.md | 44 ++++-- .../control/ControlApplication.java | 61 +++++++- .../control/auth/CredentialStore.java | 114 ++++++++++++++- .../control/auth/WebSessionStore.java | 85 +++++++++++ .../control/http/ControlHttpServer.java | 133 ++++++++++++++++-- src/main/resources/web/app.css | 3 +- src/main/resources/web/app.js | 72 +++++++--- src/main/resources/web/index.html | 11 +- .../control/ControlApplicationTest.java | 13 ++ .../control/auth/CredentialStoreTest.java | 18 +++ .../control/http/ControlHttpServerTest.java | 45 +++++- 11 files changed, 541 insertions(+), 58 deletions(-) create mode 100644 src/main/java/com/bencodez/votingplugin/control/auth/WebSessionStore.java diff --git a/README.md b/README.md index eaa91925..1311072b 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,11 @@ Browser -> Control WebUI/API <- Proxy A / Proxy B / Proxy C <- backend servers Proxy connectors initiate outbound HTTP(S) requests. Bukkit backend nodes do not connect directly. Control works without Internet or a cloud account. Node calls require explicitly created, node-bound bearer credentials; management reads -require a separate admin credential. Raw credentials are never stored by Control: `data/credentials.json` contains only -SHA-256 verifiers for 256-bit random tokens. +require a separate admin credential. Browser management uses a distinct owner-chosen password. Raw credentials are never +stored by Control: `data/credentials.json` contains SHA-256 verifiers for 256-bit random tokens and a salted, +600,000-iteration PBKDF2-HMAC-SHA256 WebUI password verifier. -Control binds to `127.0.0.1` by default. A non-loopback bind is refused until an admin credential exists. Authentication +Control binds to `127.0.0.1` by default. A non-loopback bind is refused until an admin token or WebUI password exists. Authentication does not encrypt traffic: use HTTPS or a trusted private tunnel/network when traffic can cross an untrusted network. Do not publish the HTTP service directly to the Internet. @@ -37,20 +38,30 @@ report `development` rather than maintaining a second version literal. | Variable | Default | Constraint | | --- | --- | --- | -| `CONTROL_HOST` | `127.0.0.1` | Valid bind host; non-loopback requires an admin credential | +| `CONTROL_HOST` | `127.0.0.1` | Valid bind host; non-loopback requires an admin token or WebUI password | | `CONTROL_PORT` | `8080` | `0`–`65535`; `0` selects a free port | | `CONTROL_DATA_DIR` | `data` | Writable directory for identity and credential verifiers | | `CONTROL_OFFLINE_TIMEOUT_SECONDS` | `90` | `1`–`3600` | | `CONTROL_REQUEST_TIMEOUT_SECONDS` | `10` | `1`–`60`; bounds stalled JDK HTTP exchanges | +| `CONTROL_SECURE_COOKIE` | `false` | Set `true` only when browsers reach Control through a trusted HTTPS reverse proxy | The server also uses a bounded HTTP executor (8 active requests and a 32-request queue), a 64 KiB request limit, bounded JSON depth/string/number sizes, and a bounded invalid-authentication failure limit. Valid enrolled/admin credentials remain usable even while invalid traffic is throttled. Shutdown stops the server and its daemon request workers without waiting indefinitely. -Open `http://127.0.0.1:8080/` for the local WebUI. Its static shell is public on the Control listener, but topology and -configuration operations require the admin bearer token. The token and one-time preview approval remain only in page -memory. Static assets use a restrictive Content Security Policy and the browser calls the same `/api/v1` endpoints. +Set the WebUI password from an interactive console, then open `http://127.0.0.1:8080/`: + +```shell +java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar web-password data +``` + +The password is read twice without echo and never accepted as a command-line argument. The static shell is public on the +Control listener, but topology and configuration operations require either the admin bearer token (API automation) or a +password-authenticated browser session. Browser sessions are bounded to 100, expire after 30 minutes idle or 8 hours +absolute, use an HttpOnly/SameSite=Strict cookie, require a per-session CSRF token for writes, and are invalidated +immediately by password rotation. Cookies gain the `Secure` flag when Control is served through HTTPS. Static assets use a +restrictive Content Security Policy and the browser calls the same `/api/v1` endpoints. ## Enrollment @@ -66,6 +77,9 @@ java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar revoke proxy-a data # Create or rotate the credential used for management GET endpoints java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar admin-token data + +# Create or rotate the interactive WebUI password (prompted; never passed in argv) +java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar web-password data ``` Rotating replaces the prior verifier immediately. The store is reread for every authentication decision, so a running @@ -91,16 +105,19 @@ All errors have the stable form: | --- | --- | --- | --- | | `GET` | `/`, `/app.js`, `/app.css` | none | Minimal local WebUI static assets | | `GET` | `/api/v1/health` | none | Health plus Control identity/application/protocol version | +| `POST` | `/api/v1/auth/login` | WebUI password | Create a bounded browser session and return its CSRF token | +| `GET` | `/api/v1/auth/session` | WebUI session | Restore the in-memory CSRF token after a page refresh; invalid/absent sessions return `204` | +| `POST` | `/api/v1/auth/logout` | WebUI session + CSRF | Revoke the current browser session and expire its cookie | | `POST` | `/api/v1/nodes/register` | node | Idempotently create/replace one enrolled proxy session | | `PUT` | `/api/v1/nodes/{nodeId}/heartbeat` | matching node | Refresh liveness and replace capability advertisement | | `PUT` | `/api/v1/nodes/{nodeId}/presence` | matching node | Replace that session's backend snapshot | | `POST` | `/api/v1/nodes/{nodeId}/operations` | matching node | Claim one queued configuration task, or `204` | | `POST` | `/api/v1/nodes/{nodeId}/operations/{operationId}/result` | matching node | Complete that node's task | -| `GET` | `/api/v1/nodes?offset=0&limit=50` | admin | Stable node-ID ordering; limit `1`–`100` | -| `POST` | `/api/v1/configuration/read` | admin | Queue a typed read for selected capable nodes | -| `POST` | `/api/v1/configuration/preview` | admin | Queue independent validation and normalized diffs | -| `POST` | `/api/v1/configuration/apply` | admin + one-time approval | Apply the exact successful preview | -| `GET` | `/api/v1/operations/{operationId}` | admin | Read overall and per-node operation status | +| `GET` | `/api/v1/nodes?offset=0&limit=50` | admin or WebUI session | Stable node-ID ordering; limit `1`–`100` | +| `POST` | `/api/v1/configuration/read` | admin or WebUI session + CSRF | Queue a typed read for selected capable nodes | +| `POST` | `/api/v1/configuration/preview` | admin or WebUI session + CSRF | Queue independent validation and normalized diffs | +| `POST` | `/api/v1/configuration/apply` | admin or WebUI session + CSRF, plus one-time approval | Apply the exact successful preview | +| `GET` | `/api/v1/operations/{operationId}` | admin or WebUI session | Read overall and per-node operation status | Routes are exact. Child suffixes do not inherit a handler, every known endpoint has an intentional method/structured 405, and all unknown endpoints return a structured 404. @@ -132,11 +149,12 @@ They are not misleadingly used by these simple HTTP resource endpoints. ## Troubleshooting - `401 UNAUTHORIZED`: the credential is missing, invalid, revoked, rotated, or enrolled for another node ID. +- `403 CSRF_REQUIRED`: refresh/sign in again and retry the WebUI write from the same session. - `409 SESSION_MISMATCH`: re-register; Control has a newer process session for that stable node. - `409 UNSUPPORTED_PROTOCOL` / `INCOMPATIBLE_CAPABILITIES`: upgrade the older side before retrying. - Node stays offline: verify the proxy connector is enabled, the node ID matches enrollment, and heartbeat timeouts permit the configured interval. -- LAN startup is refused: create the admin token first and protect transport with HTTPS/private tunneling. +- LAN startup is refused: create the admin token or WebUI password first and protect transport with HTTPS/private tunneling. - Corrupt `instance-id` or credential data fails closed; restore a known backup or deliberately recreate the affected file and re-enroll nodes. diff --git a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java index fa36b8b7..2c1ddca1 100644 --- a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java +++ b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java @@ -45,11 +45,17 @@ public static void main(String[] args) { } private static void runOwnerCommand(String[] args) throws IOException { - runOwnerCommand(args, System.getenv(), System.out); + runOwnerCommand(args, System.getenv(), System.out, ControlApplication::readWebPassword); } static void runOwnerCommand(String[] args, Map environment, PrintStream output) throws IOException { + runOwnerCommand(args, environment, output, + () -> { throw new IllegalArgumentException("A console is required to read the WebUI password"); }); + } + + static void runOwnerCommand(String[] args, Map environment, PrintStream output, + PasswordReader passwordReader) throws IOException { switch (args[0]) { case "enroll" -> { if (args.length < 2 || args.length > 3) { @@ -73,10 +79,39 @@ static void runOwnerCommand(String[] args, Map environment, Prin Path selected = ownerDataDirectory(args.length == 2 ? args[1] : null, environment); output.println(new CredentialStore(selected).rotateAdmin()); } + case "web-password" -> { + if (args.length > 2) { + throw new IllegalArgumentException("Usage: web-password [dataDirectory]"); + } + Path selected = ownerDataDirectory(args.length == 2 ? args[1] : null, environment); + char[] password = passwordReader.read(); + try { + new CredentialStore(selected).setWebPassword(password); + } finally { + if (password != null) java.util.Arrays.fill(password, '\0'); + } + output.println("WebUI password updated. Existing browser sessions are now invalid."); + } default -> throw new IllegalArgumentException("Unknown command"); } } + private static char[] readWebPassword() { + java.io.Console console = System.console(); + if (console == null) { + throw new IllegalArgumentException("The web-password command requires an interactive console"); + } + char[] first = console.readPassword("New WebUI password: "); + char[] second = console.readPassword("Confirm WebUI password: "); + if (first == null || second == null || !java.util.Arrays.equals(first, second)) { + if (first != null) java.util.Arrays.fill(first, '\0'); + if (second != null) java.util.Arrays.fill(second, '\0'); + throw new IllegalArgumentException("WebUI passwords did not match"); + } + java.util.Arrays.fill(second, '\0'); + return first; + } + private static Path ownerDataDirectory(String explicit, Map environment) { return requirePath(explicit != null ? explicit : environment.getOrDefault("CONTROL_DATA_DIR", "data")); } @@ -84,8 +119,9 @@ private static Path ownerDataDirectory(String explicit, Map envi static void runServer(Map environment) throws Exception { Configuration configuration = Configuration.from(environment); CredentialStore credentials = new CredentialStore(configuration.dataDirectory()); - if (!configuration.address().getAddress().isLoopbackAddress() && !credentials.hasAdmin()) { - throw new IllegalArgumentException("An admin credential is required for non-loopback binding"); + if (!configuration.address().getAddress().isLoopbackAddress() + && !credentials.hasAdmin() && !credentials.hasWebPassword()) { + throw new IllegalArgumentException("An admin token or WebUI password is required for non-loopback binding"); } // JDK HttpServer honors these bounds for stalled request and response bodies. System.setProperty("sun.net.httpserver.maxReqTime", Integer.toString(configuration.requestTimeoutSeconds())); @@ -97,7 +133,7 @@ static void runServer(Map environment) throws Exception { ConfigurationOperations operations = new ConfigurationOperations(registry, new ConfigurationAuditLog(configuration.dataDirectory(), clock), clock); ControlHttpServer server = new ControlHttpServer(configuration.address(), registry, identity, credentials, - operations); + operations, configuration.secureCookies()); CountDownLatch shutdown = new CountDownLatch(1); Runtime.getRuntime().addShutdownHook(new Thread(() -> { server.close(); @@ -150,7 +186,7 @@ private static Path requirePath(String value) { } record Configuration(InetSocketAddress address, Path dataDirectory, Duration offlineTimeout, - int requestTimeoutSeconds) { + int requestTimeoutSeconds, boolean secureCookies) { static Configuration from(Map environment) throws IOException { String host = environment.getOrDefault("CONTROL_HOST", "127.0.0.1").trim(); if (host.isEmpty()) { @@ -162,9 +198,11 @@ static Configuration from(Map environment) throws IOException { "offline timeout", 1, 3600); int requestSeconds = boundedInteger(environment.getOrDefault("CONTROL_REQUEST_TIMEOUT_SECONDS", "10"), "request timeout", 1, 60); + boolean secureCookies = strictBoolean(environment.getOrDefault("CONTROL_SECURE_COOKIE", "false"), + "secure cookie"); return new Configuration(new InetSocketAddress(resolved, port), requirePath(environment.getOrDefault("CONTROL_DATA_DIR", "data")), - Duration.ofSeconds(offlineSeconds), requestSeconds); + Duration.ofSeconds(offlineSeconds), requestSeconds, secureCookies); } private static int boundedInteger(String value, String name, int minimum, int maximum) { @@ -179,5 +217,16 @@ private static int boundedInteger(String value, String name, int minimum, int ma } return parsed; } + + private static boolean strictBoolean(String value, String name) { + if ("true".equalsIgnoreCase(value)) return true; + if ("false".equalsIgnoreCase(value)) return false; + throw new IllegalArgumentException("Invalid " + name); + } + } + + @FunctionalInterface + interface PasswordReader { + char[] read(); } } diff --git a/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java b/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java index 91f06d1a..dad74c2a 100644 --- a/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java +++ b/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java @@ -13,17 +13,25 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; import java.util.Base64; import java.util.HashMap; import java.util.HexFormat; import java.util.Map; import java.util.Objects; import java.util.regex.Pattern; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; -/** Persistent SHA-256 verifiers for high-entropy enrollment and local admin credentials. */ +/** Persistent token verifiers and a salted PBKDF2 WebUI password verifier. */ public final class CredentialStore { private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final int MAX_ENROLLMENTS = 10_000; + private static final int WEB_PASSWORD_ITERATIONS = 600_000; + private static final int WEB_PASSWORD_BYTES = 32; + private static final int WEB_PASSWORD_MINIMUM = 12; + private static final byte[] DUMMY_PASSWORD_SALT = new byte[16]; + private static final byte[] DUMMY_PASSWORD_HASH = new byte[WEB_PASSWORD_BYTES]; private static final byte[] DUMMY_HASH = new byte[32]; private final Path directory; @@ -75,6 +83,85 @@ public boolean hasAdmin() throws IOException { return hash != null && !hash.isBlank(); } + /** Replaces the WebUI password verifier. The supplied password is never persisted. */ + public void setWebPassword(char[] password) throws IOException { + validateWebPassword(password); + byte[] salt = new byte[16]; + random.nextBytes(salt); + byte[] verifier = derivePassword(password, salt, WEB_PASSWORD_ITERATIONS); + mutate(data -> { + data.webPasswordSalt = Base64.getEncoder().encodeToString(salt); + data.webPasswordHash = Base64.getEncoder().encodeToString(verifier); + data.webPasswordIterations = WEB_PASSWORD_ITERATIONS; + }); + } + + public boolean hasWebPassword() throws IOException { + return passwordRevision(read()) != null; + } + + /** Opaque verifier revision used to invalidate sessions immediately after password rotation. */ + public String webPasswordRevision() { + try { + StoreData data = read(); + return passwordRevision(data); + } catch (IOException e) { + return null; + } + } + + /** Performs the same bounded password derivation even when the verifier is absent or malformed. */ + public boolean verifyWebPassword(String password) { + return authenticateWebPassword(password) != null; + } + + /** Returns an opaque verifier revision only when the supplied password is valid. */ + public String authenticateWebPassword(String password) { + byte[] salt = DUMMY_PASSWORD_SALT; + byte[] expected = DUMMY_PASSWORD_HASH; + int iterations = WEB_PASSWORD_ITERATIONS; + boolean configured = false; + String revision = null; + try { + StoreData data = read(); + if (data.webPasswordSalt != null && data.webPasswordHash != null + && data.webPasswordIterations >= 100_000 && data.webPasswordIterations <= 1_000_000) { + byte[] parsedSalt = Base64.getDecoder().decode(data.webPasswordSalt); + byte[] parsedHash = Base64.getDecoder().decode(data.webPasswordHash); + if (parsedSalt.length == 16 && parsedHash.length == WEB_PASSWORD_BYTES) { + salt = parsedSalt; + expected = parsedHash; + iterations = data.webPasswordIterations; + configured = true; + revision = passwordRevision(data); + } + } + } catch (IOException | IllegalArgumentException ignored) { + // Authentication remains closed if the verifier cannot be read. + } + char[] supplied = password == null ? new char[0] : password.toCharArray(); + try { + byte[] actual = derivePassword(supplied, salt, iterations); + return configured && password != null && MessageDigest.isEqual(expected, actual) ? revision : null; + } finally { + java.util.Arrays.fill(supplied, '\0'); + } + } + + private static String passwordRevision(StoreData data) { + try { + if (data.webPasswordSalt == null || data.webPasswordHash == null + || data.webPasswordIterations < 100_000 || data.webPasswordIterations > 1_000_000 + || Base64.getDecoder().decode(data.webPasswordSalt).length != 16 + || Base64.getDecoder().decode(data.webPasswordHash).length != WEB_PASSWORD_BYTES) { + return null; + } + return hashHex(data.webPasswordSalt + ":" + data.webPasswordHash + ":" + data.webPasswordIterations); + } catch (IllegalArgumentException e) { + return null; + } + } + public boolean verifyNode(String nodeId, String token) { String expected = null; try { @@ -172,6 +259,28 @@ private static byte[] hash(String token) { } } + private static byte[] derivePassword(char[] password, byte[] salt, int iterations) { + PBEKeySpec spec = new PBEKeySpec(password, salt, iterations, WEB_PASSWORD_BYTES * 8); + try { + return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded(); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new IllegalStateException("PBKDF2-HMAC-SHA256 is unavailable", e); + } finally { + spec.clearPassword(); + } + } + + private static void validateWebPassword(char[] password) { + if (password == null || password.length < WEB_PASSWORD_MINIMUM || password.length > 256) { + throw new IllegalArgumentException("WebUI password must contain 12 to 256 characters"); + } + for (char value : password) { + if (Character.isISOControl(value)) { + throw new IllegalArgumentException("WebUI password cannot contain control characters"); + } + } + } + private static void validateNodeId(String nodeId) { if (nodeId == null || !NODE_ID.matcher(nodeId).matches()) { throw new IllegalArgumentException("nodeId must match " + NODE_ID.pattern()); @@ -185,6 +294,9 @@ private interface Mutation { private static final class StoreData { public String adminHash; + public String webPasswordSalt; + public String webPasswordHash; + public int webPasswordIterations; public Map nodeHashes = new HashMap<>(); } } diff --git a/src/main/java/com/bencodez/votingplugin/control/auth/WebSessionStore.java b/src/main/java/com/bencodez/votingplugin/control/auth/WebSessionStore.java new file mode 100644 index 00000000..94b7dbc3 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/auth/WebSessionStore.java @@ -0,0 +1,85 @@ +package com.bencodez.votingplugin.control.auth; + +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Bounded, in-memory WebUI sessions with idle and absolute expiry. */ +public final class WebSessionStore { + private static final int MAX_SESSIONS = 100; + private static final Duration IDLE_TIMEOUT = Duration.ofMinutes(30); + private static final Duration ABSOLUTE_TIMEOUT = Duration.ofHours(8); + + private final Clock clock; + private final SecureRandom random; + private final LinkedHashMap sessions = new LinkedHashMap<>(); + + public WebSessionStore(Clock clock) { + this(clock, new SecureRandom()); + } + + WebSessionStore(Clock clock, SecureRandom random) { + this.clock = Objects.requireNonNull(clock, "clock"); + this.random = Objects.requireNonNull(random, "random"); + } + + public synchronized Session create(String credentialRevision) { + Objects.requireNonNull(credentialRevision, "credentialRevision"); + Instant now = clock.instant(); + removeExpired(now); + while (sessions.size() >= MAX_SESSIONS) { + sessions.remove(sessions.keySet().iterator().next()); + } + String id = token(); + Session session = new Session(id, token(), credentialRevision); + sessions.put(id, new StoredSession(session, now, now)); + return session; + } + + public synchronized Session authenticate(String id, String credentialRevision) { + if (id == null || credentialRevision == null) return null; + Instant now = clock.instant(); + StoredSession stored = sessions.get(id); + if (stored == null || expired(stored, now) + || !MessageDigest.isEqual(stored.session().credentialRevision() + .getBytes(java.nio.charset.StandardCharsets.UTF_8), + credentialRevision.getBytes(java.nio.charset.StandardCharsets.UTF_8))) { + sessions.remove(id); + return null; + } + sessions.put(id, new StoredSession(stored.session(), stored.created(), now)); + return stored.session(); + } + + public synchronized void remove(String id) { + if (id != null) sessions.remove(id); + } + + private void removeExpired(Instant now) { + Iterator> iterator = sessions.entrySet().iterator(); + while (iterator.hasNext()) { + if (expired(iterator.next().getValue(), now)) iterator.remove(); + } + } + + private static boolean expired(StoredSession stored, Instant now) { + return !now.isBefore(stored.lastUsed().plus(IDLE_TIMEOUT)) + || !now.isBefore(stored.created().plus(ABSOLUTE_TIMEOUT)); + } + + private String token() { + byte[] bytes = new byte[32]; + random.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + public record Session(String id, String csrfToken, String credentialRevision) { } + private record StoredSession(Session session, Instant created, Instant lastUsed) { } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 2a485a1e..cc93c874 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.control.http; import com.bencodez.votingplugin.control.auth.CredentialStore; +import com.bencodez.votingplugin.control.auth.WebSessionStore; import com.bencodez.votingplugin.control.domain.NodeRegistry; import com.bencodez.votingplugin.control.domain.ConfigurationOperations; import com.bencodez.votingplugin.control.domain.ValidationException; @@ -20,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpsExchange; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -31,6 +33,7 @@ import java.nio.charset.CharacterCodingException; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.time.Clock; import java.time.Instant; import java.util.HashMap; @@ -52,6 +55,10 @@ public final class ControlHttpServer implements AutoCloseable { private static final String REGISTER = "/api/v1/nodes/register"; private static final String CONFIGURATION = "/api/v1/configuration"; private static final String OPERATIONS = "/api/v1/operations"; + private static final String AUTH_LOGIN = "/api/v1/auth/login"; + private static final String AUTH_SESSION = "/api/v1/auth/session"; + private static final String AUTH_LOGOUT = "/api/v1/auth/logout"; + private static final String SESSION_COOKIE = "vpctl_session"; private static final Map WEB_RESOURCES = Map.of( "/", new WebResource("/web/index.html", "text/html; charset=utf-8"), "/index.html", new WebResource("/web/index.html", "text/html; charset=utf-8"), @@ -67,28 +74,39 @@ public final class ControlHttpServer implements AutoCloseable { private final ConfigurationOperations configurationOperations; private final ThreadPoolExecutor executor; private final AuthFailureLimiter authLimiter; + private final WebSessionStore webSessions; + private final boolean secureCookies; public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials) throws IOException { this(address, registry, identity, credentials, new ConfigurationOperations(registry, new com.bencodez.votingplugin.control.domain.ConfigurationAuditLog( java.nio.file.Files.createTempDirectory("votingplugin-control-test-audit"), Clock.systemUTC()), - Clock.systemUTC()), Clock.systemUTC(), System::nanoTime); + Clock.systemUTC()), Clock.systemUTC(), System::nanoTime, false); } public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials, ConfigurationOperations configurationOperations) throws IOException { - this(address, registry, identity, credentials, configurationOperations, Clock.systemUTC(), System::nanoTime); + this(address, registry, identity, credentials, configurationOperations, Clock.systemUTC(), System::nanoTime, + false); + } + + public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, + CredentialStore credentials, ConfigurationOperations configurationOperations, + boolean secureCookies) throws IOException { + this(address, registry, identity, credentials, configurationOperations, Clock.systemUTC(), System::nanoTime, + secureCookies); } ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials, ConfigurationOperations configurationOperations, Clock clock, - java.util.function.LongSupplier nanoTime) throws IOException { + java.util.function.LongSupplier nanoTime, boolean secureCookies) throws IOException { this.registry = Objects.requireNonNull(registry, "registry"); this.identity = Objects.requireNonNull(identity, "identity"); this.credentials = Objects.requireNonNull(credentials, "credentials"); this.configurationOperations = Objects.requireNonNull(configurationOperations, "configurationOperations"); + this.secureCookies = secureCookies; Objects.requireNonNull(clock, "clock"); json = new ObjectMapper(); json.findAndRegisterModules(); @@ -99,6 +117,7 @@ public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, Contr json.getFactory().setStreamReadConstraints(StreamReadConstraints.builder() .maxNestingDepth(20).maxStringLength(4096).maxNumberLength(64).build()); authLimiter = new AuthFailureLimiter(nanoTime, MAX_AUTH_FAILURES_PER_MINUTE, TimeUnit.MINUTES.toNanos(1)); + webSessions = new WebSessionStore(clock); server = HttpServer.create(address, 32); server.createContext("/", this::handle); ThreadFactory threads = runnable -> { @@ -137,6 +156,8 @@ private void handle(HttpExchange exchange) throws IOException { exchange.getResponseHeaders().set("WWW-Authenticate", "Bearer"); error(exchange, e.rateLimited ? 429 : 401, e.rateLimited ? "AUTH_RATE_LIMITED" : "UNAUTHORIZED", e.rateLimited ? "Too many authentication failures" : "Authentication failed", List.of()); + } catch (CsrfException e) { + error(exchange, 403, "CSRF_REQUIRED", "A valid CSRF token is required", List.of()); } catch (ValidationException e) { int status = switch (e.code()) { case "NODE_NOT_FOUND", "OPERATION_NOT_FOUND" -> 404; @@ -177,9 +198,43 @@ private void route(HttpExchange exchange) throws IOException { send(exchange, 200, Map.of("status", "ok", "time", Instant.now(), "identity", identity)); return; } + if (AUTH_LOGIN.equals(path)) { + requireMethod(exchange, "POST"); + if (!authLimiter.allowAttempt()) throw new AuthenticationException(true); + PasswordRequest request = read(exchange, PasswordRequest.class); + String credentialRevision = request == null ? null : credentials.authenticateWebPassword(request.password()); + if (credentialRevision == null) { + authLimiter.recordFailure(); + throw new AuthenticationException(false); + } + webSessions.remove(cookie(exchange, SESSION_COOKIE)); + WebSessionStore.Session session = webSessions.create(credentialRevision); + setSessionCookie(exchange, session.id(), false); + send(exchange, 200, Map.of("csrfToken", session.csrfToken())); + return; + } + if (AUTH_SESSION.equals(path)) { + requireMethod(exchange, "GET"); + WebSessionStore.Session session = webSessions.authenticate(cookie(exchange, SESSION_COOKIE), + credentials.webPasswordRevision()); + if (session == null) { + noContent(exchange); + return; + } + send(exchange, 200, Map.of("csrfToken", session.csrfToken())); + return; + } + if (AUTH_LOGOUT.equals(path)) { + requireMethod(exchange, "POST"); + WebSessionStore.Session session = authenticateWebSession(exchange, true); + webSessions.remove(session.id()); + setSessionCookie(exchange, "", true); + send(exchange, 200, Map.of("loggedOut", true)); + return; + } if (NODES.equals(path)) { requireMethod(exchange, "GET"); - authenticateAdmin(exchange); + authenticateAdmin(exchange, false); Map query = query(uri.getRawQuery()); int offset = integer(query.getOrDefault("offset", "0"), "offset"); int limit = integer(query.getOrDefault("limit", "50"), "limit"); @@ -202,21 +257,21 @@ private void route(HttpExchange exchange) throws IOException { } if ((CONFIGURATION + "/read").equals(path)) { requireMethod(exchange, "POST"); - authenticateAdmin(exchange); + authenticateAdmin(exchange, true); ConfigurationRequests.Read request = read(exchange, ConfigurationRequests.Read.class); send(exchange, 202, configurationOperations.createRead(request.nodeIds())); return; } if ((CONFIGURATION + "/preview").equals(path)) { requireMethod(exchange, "POST"); - authenticateAdmin(exchange); + authenticateAdmin(exchange, true); ConfigurationRequests.Preview request = read(exchange, ConfigurationRequests.Preview.class); send(exchange, 202, configurationOperations.createPreview(request.nodeIds(), request.configuration())); return; } if ((CONFIGURATION + "/apply").equals(path)) { requireMethod(exchange, "POST"); - authenticateAdmin(exchange); + authenticateAdmin(exchange, true); ConfigurationRequests.Apply request = read(exchange, ConfigurationRequests.Apply.class); send(exchange, 202, configurationOperations.createApply(request.previewOperationId(), request.approvalToken())); return; @@ -225,7 +280,7 @@ private void route(HttpExchange exchange) throws IOException { String remainder = path.substring((OPERATIONS + "/").length()); if (!remainder.contains("/")) { requireMethod(exchange, "GET"); - authenticateAdmin(exchange); + authenticateAdmin(exchange, false); send(exchange, 200, configurationOperations.get(UUID.fromString(remainder))); return; } @@ -322,8 +377,11 @@ private void authenticateNode(HttpExchange exchange, String nodeId) { authenticate(exchange, token -> credentials.verifyNode(nodeId, token)); } - private void authenticateAdmin(HttpExchange exchange) { - authenticate(exchange, credentials::verifyAdmin); + private void authenticateAdmin(HttpExchange exchange, boolean csrfRequired) { + if (credentials.verifyAdmin(bearer(exchange))) { + return; + } + authenticateWebSession(exchange, csrfRequired); } private void authenticate(HttpExchange exchange, java.util.function.Predicate verifier) { @@ -331,9 +389,23 @@ private void authenticate(HttpExchange exchange, java.util.function.Predicate headers = exchange.getRequestHeaders().get("Cookie"); + if (headers == null) return null; + for (String header : headers) { + for (String item : header.split(";")) { + String[] pair = item.trim().split("=", 2); + if (pair.length == 2 && name.equals(pair[0])) { + if (found != null) return null; + found = pair[1]; + } + } + } + return found; + } + + private static String singleHeader(HttpExchange exchange, String name) { + List values = exchange.getRequestHeaders().get(name); + return values != null && values.size() == 1 ? values.get(0) : null; + } + + private static boolean constantTimeEquals(String expected, String actual) { + byte[] first = expected == null ? new byte[32] : expected.getBytes(StandardCharsets.UTF_8); + byte[] second = actual == null ? new byte[32] : actual.getBytes(StandardCharsets.UTF_8); + return expected != null && actual != null && MessageDigest.isEqual(first, second); + } + + private void setSessionCookie(HttpExchange exchange, String value, boolean expired) { + String cookie = SESSION_COOKIE + "=" + value + "; Path=/; HttpOnly; SameSite=Strict" + + (secureCookies || exchange instanceof HttpsExchange ? "; Secure" : "") + + (expired ? "; Max-Age=0" : ""); + exchange.getResponseHeaders().add("Set-Cookie", cookie); + } + private void requireMethod(HttpExchange exchange, String expected) throws IOException { if (expected.equals(exchange.getRequestMethod())) { return; @@ -453,9 +559,12 @@ private AuthenticationException(boolean rateLimited) { } } @SuppressWarnings("serial") + private static final class CsrfException extends RuntimeException { } + @SuppressWarnings("serial") private static final class ResponseCompleteException extends RuntimeException { } private record WebResource(String classpath, String contentType) { } + private record PasswordRequest(String password) { } static final class AuthFailureLimiter { private final java.util.function.LongSupplier nanoTime; diff --git a/src/main/resources/web/app.css b/src/main/resources/web/app.css index 4ae8b6b1..e0f80f2d 100644 --- a/src/main/resources/web/app.css +++ b/src/main/resources/web/app.css @@ -9,6 +9,7 @@ body { margin: 0; min-height: 100vh; background: radial-gradient(circle at top, #163158 0, #08111f 45%); } main { width: min(1040px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0 80px; } header, .section-title, .auth-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.auth-actions { display: flex; gap: 10px; } h1, h2, h3, p { margin-top: 0; } h1 { margin-bottom: 0; font-size: clamp(2rem, 5vw, 3.5rem); } .eyebrow { margin-bottom: 6px; color: #72b7ff; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; } @@ -37,4 +38,4 @@ textarea { width: 100%; resize: vertical; margin-bottom: 14px; } ul { margin: 0; padding: 0; list-style: none; } li { display: flex; flex-direction: column; gap: 3px; padding: 10px 0; border-top: 1px solid #263b57; } li span { color: #aabbd0; font-size: .9rem; } -@media (max-width: 640px) { header, .auth-row { align-items: stretch; flex-direction: column; } .status { align-self: flex-start; } .operation-actions button { width: 100%; } } +@media (max-width: 640px) { header, .auth-row { align-items: stretch; flex-direction: column; } .status { align-self: flex-start; } .auth-actions button, .operation-actions button { width: 100%; } } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index e630e448..75975586 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -2,7 +2,8 @@ const health = document.querySelector('#health'); const form = document.querySelector('#auth-form'); -const tokenInput = document.querySelector('#token'); +const passwordInput = document.querySelector('#password'); +const logout = document.querySelector('#logout'); const message = document.querySelector('#message'); const nodes = document.querySelector('#nodes'); const refresh = document.querySelector('#refresh'); @@ -16,7 +17,8 @@ const previewConfiguration = document.querySelector('#preview-configuration'); const applyConfiguration = document.querySelector('#apply-configuration'); const operationStatus = document.querySelector('#operation-status'); const PAGE_SIZE = 100; -let adminToken = ''; +let authenticated = false; +let csrfToken = ''; let pageOffset = 0; let selectedNodes = new Set(); let approvedPreview = null; @@ -79,17 +81,19 @@ function nodeCard(node) { } function updateConfigurationButtons(busy = false) { - const ready = adminToken && selectedNodes.size > 0 && !busy; + const ready = authenticated && selectedNodes.size > 0 && !busy; readConfiguration.disabled = !ready; previewConfiguration.disabled = !ready; applyConfiguration.disabled = !ready || !approvedPreview; } async function authorized(path, options = {}) { + const method = (options.method || 'GET').toUpperCase(); const response = await fetch(path, { cache: 'no-store', + credentials: 'same-origin', ...options, - headers: {...(options.headers || {}), 'Authorization': `Bearer ${adminToken}`} + headers: {...(options.headers || {}), ...(method === 'GET' ? {} : {'X-CSRF-Token': csrfToken})} }); const body = response.status === 204 ? null : await response.json(); if (!response.ok) throw new Error(body?.error?.message || `Control request failed (${response.status}).`); @@ -136,20 +140,13 @@ async function startConfigurationOperation(path, body) { } async function loadNodes() { - if (!adminToken) return; + if (!authenticated) return; refresh.disabled = true; previousPage.disabled = true; nextPage.disabled = true; text(message, 'Loading…'); try { - const response = await fetch(`/api/v1/nodes?offset=${pageOffset}&limit=${PAGE_SIZE}`, { - cache: 'no-store', - headers: {'Authorization': `Bearer ${adminToken}`} - }); - if (!response.ok) { - throw new Error(response.status === 401 || response.status === 429 ? 'Authentication failed.' : 'Control request failed.'); - } - const body = await response.json(); + const body = await authorized(`/api/v1/nodes?offset=${pageOffset}&limit=${PAGE_SIZE}`); nodes.replaceChildren(); nodes.classList.toggle('empty', body.items.length === 0); if (body.items.length === 0) { @@ -177,14 +174,52 @@ async function loadNodes() { } } -form.addEventListener('submit', event => { +form.addEventListener('submit', async event => { event.preventDefault(); - adminToken = tokenInput.value.trim(); - tokenInput.value = ''; - pageOffset = 0; - loadNodes(); + const password = passwordInput.value; + passwordInput.value = ''; + try { + const response = await fetch('/api/v1/auth/login', { + method: 'POST', cache: 'no-store', credentials: 'same-origin', + headers: {'Content-Type': 'application/json'}, body: JSON.stringify({password}) + }); + const body = await response.json(); + if (!response.ok) throw new Error(body?.error?.message || 'Authentication failed.'); + authenticated = true; + csrfToken = body.csrfToken; + logout.hidden = false; + pageOffset = 0; + await loadNodes(); + } catch (error) { + text(message, error.message || 'Authentication failed.'); + } +}); + +logout.addEventListener('click', async () => { + try { await authorized('/api/v1/auth/logout', {method: 'POST'}); } catch (_) { /* Expired is logged out too. */ } + authenticated = false; + csrfToken = ''; + logout.hidden = true; + selectedNodes.clear(); + nodes.replaceChildren(); + nodes.classList.add('empty'); + text(nodes, 'Authenticate to view the network.'); + text(message, 'Signed out.'); + updateConfigurationButtons(); }); +async function restoreSession() { + try { + const response = await fetch('/api/v1/auth/session', {cache: 'no-store', credentials: 'same-origin'}); + if (!response.ok || response.status === 204) return; + const body = await response.json(); + authenticated = true; + csrfToken = body.csrfToken; + logout.hidden = false; + await loadNodes(); + } catch (_) { /* The login form remains available. */ } +} + readConfiguration.addEventListener('click', async () => { approvedPreview = null; try { @@ -236,3 +271,4 @@ nextPage.addEventListener('click', () => { loadNodes(); }); loadHealth(); +restoreSession(); diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 65ae0e51..cc281ce5 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -18,12 +18,15 @@

VotingPlugin Control

Network discovery

-

Enter the local admin token to load enrolled proxies and their observed backend servers. The token stays in this page's memory and is never saved.

+

Sign in with the WebUI password configured by the Control owner. Sessions use an HttpOnly cookie and expire automatically.

- +
- - + +
+ + +

diff --git a/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java index 15c5e677..92fa86b2 100644 --- a/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java @@ -67,6 +67,19 @@ class ControlApplicationTest { assertTrue(output.toString().trim().startsWith("vpctl_admin_")); } + @Test void webPasswordCommandReadsSecretOutOfBand() throws Exception { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + char[] supplied = "command-password-value".toCharArray(); + + ControlApplication.runOwnerCommand(new String[]{"web-password", directory.toString()}, + java.util.Map.of(), new PrintStream(output), () -> supplied); + + assertTrue(new com.bencodez.votingplugin.control.auth.CredentialStore(directory) + .verifyWebPassword("command-password-value")); + assertTrue(output.toString().contains("WebUI password updated")); + assertTrue(java.util.Arrays.equals(new char[supplied.length], supplied)); + } + @Test void startupConfigurationValidatesAllBounds() { assertThrows(IllegalArgumentException.class, () -> ControlApplication.Configuration.from(java.util.Map.of("CONTROL_PORT", "70000"))); diff --git a/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java index 8c0d6c05..7650ae52 100644 --- a/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java @@ -37,4 +37,22 @@ class CredentialStoreTest { assertFalse(store.verifyAdmin(token)); assertTrue(store.verifyAdmin(replacement)); } + + @Test void webPasswordUsesSaltedPbkdf2VerifierAndRotates() throws Exception { + CredentialStore store = new CredentialStore(directory); + assertFalse(store.hasWebPassword()); + char[] first = "a-long-local-password".toCharArray(); + store.setWebPassword(first); + assertTrue(store.hasWebPassword()); + assertTrue(store.verifyWebPassword("a-long-local-password")); + assertFalse(store.verifyWebPassword("not-the-password")); + String persisted = Files.readString(directory.resolve("credentials.json")); + assertFalse(persisted.contains("a-long-local-password")); + assertTrue(persisted.contains("webPasswordSalt")); + + store.setWebPassword("a-different-long-password".toCharArray()); + assertFalse(store.verifyWebPassword("a-long-local-password")); + assertTrue(store.verifyWebPassword("a-different-long-password")); + assertThrows(IllegalArgumentException.class, () -> store.setWebPassword("too-short".toCharArray())); + } } diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index af137f01..f1a24a7c 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -16,6 +16,7 @@ import java.time.Clock; import java.time.Duration; import java.util.UUID; +import java.util.Map; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -152,6 +153,40 @@ class ControlHttpServerTest { assertEquals(201, send("POST", "/api/v1/nodes/register", registration(), nodeToken).statusCode()); } + @Test void webPasswordCreatesHttpOnlySessionRequiresCsrfAndLogsOut() throws Exception { + credentials.setWebPassword("a-secure-web-password".toCharArray()); + assertAuthFailure(send("POST", "/api/v1/auth/login", "{\"password\":\"wrong-password-value\"}", null)); + + HttpResponse login = send("POST", "/api/v1/auth/login", + "{\"password\":\"a-secure-web-password\"}", null); + assertEquals(200, login.statusCode()); + String setCookie = login.headers().firstValue("Set-Cookie").orElseThrow(); + assertTrue(setCookie.contains("HttpOnly")); + assertTrue(setCookie.contains("SameSite=Strict")); + String cookie = setCookie.substring(0, setCookie.indexOf(';')); + String csrf = json.readTree(login.body()).get("csrfToken").asText(); + + assertEquals(200, sendWithHeaders("GET", "/api/v1/auth/session", null, + Map.of("Cookie", cookie)).statusCode()); + assertEquals(200, sendWithHeaders("GET", "/api/v1/nodes", null, + Map.of("Cookie", cookie)).statusCode()); + assertError(sendWithHeaders("POST", "/api/v1/auth/logout", null, + Map.of("Cookie", cookie)), 403, "CSRF_REQUIRED"); + HttpResponse logout = sendWithHeaders("POST", "/api/v1/auth/logout", null, + Map.of("Cookie", cookie, "X-CSRF-Token", csrf)); + assertEquals(200, logout.statusCode()); + assertTrue(logout.headers().firstValue("Set-Cookie").orElseThrow().contains("Max-Age=0")); + assertEquals(204, sendWithHeaders("GET", "/api/v1/auth/session", null, Map.of("Cookie", cookie)).statusCode()); + + HttpResponse secondLogin = send("POST", "/api/v1/auth/login", + "{\"password\":\"a-secure-web-password\"}", null); + String secondCookie = secondLogin.headers().firstValue("Set-Cookie").orElseThrow(); + secondCookie = secondCookie.substring(0, secondCookie.indexOf(';')); + credentials.setWebPassword("a-rotated-web-password".toCharArray()); + assertEquals(204, sendWithHeaders("GET", "/api/v1/auth/session", null, + Map.of("Cookie", secondCookie)).statusCode()); + } + @Test void malformedEmptyNullDuplicateNestedOversizedAndLongPayloadsAreDeterministic() throws Exception { assertError(send("POST", "/api/v1/nodes/register", "{", nodeToken), 400, "MALFORMED_JSON"); assertError(send("POST", "/api/v1/nodes/register", "", nodeToken), 400, "MALFORMED_JSON"); @@ -209,10 +244,14 @@ private HttpResponse get(String path, String token) throws Exception { } private HttpResponse send(String method, String path, String body, String token) throws Exception { + return sendWithHeaders(method, path, body, + token == null ? Map.of() : Map.of("Authorization", "Bearer " + token)); + } + + private HttpResponse sendWithHeaders(String method, String path, String body, + Map headers) throws Exception { HttpRequest.Builder builder = HttpRequest.newBuilder(base.resolve(path)).timeout(Duration.ofSeconds(3)); - if (token != null) { - builder.header("Authorization", "Bearer " + token); - } + headers.forEach(builder::header); if (body != null) { builder.header("Content-Type", "application/json"); } From 0ab2e16af73648f577f6b878a196435120202206 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 24 Aug 2026 20:43:29 -0600 Subject: [PATCH 10/42] Expire stale operations and verify audit history --- README.md | 7 ++-- .../control/domain/ConfigurationAuditLog.java | 24 +++++++++--- .../domain/ConfigurationOperations.java | 16 +++++++- src/main/resources/web/app.js | 7 +++- .../domain/ConfigurationOperationsTest.java | 39 +++++++++++++++++++ .../control/http/ControlHttpServerTest.java | 1 + 6 files changed, 82 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 1311072b..9bf04116 100644 --- a/README.md +++ b/README.md @@ -134,9 +134,10 @@ single-use random approval token, carries each previewed revision to that node, network-wide success. Proxies create a local backup, require atomic replacement of the configuration file, soft-reload, and restore the backup if reload fails. -`configuration-audit.jsonl` records bounded, append-only, hash-chained operation metadata and rotates once at 5 MiB. It -does not contain configuration values, credentials, or approval tokens. Operation queues are bounded and retained in -memory for 24 hours after completion; active operations are intentionally lost on Control restart and must be previewed +`configuration-audit.jsonl` records bounded, append-only, hash-chained operation metadata and rotates once at 5 MiB. Both +the active and retained segment are verified before startup accepts new operations. It does not contain configuration +values, credentials, or approval tokens. Operation queues are bounded; abandoned operations expire after 15 minutes and +completed operations after 24 hours. Active operations are intentionally lost on Control restart and must be previewed again. A node is online only while `lastSeen + offlineTimeout` is strictly after Control's current time. The exact timeout boundary diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java index 7497cd16..0fcc1548 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java @@ -6,6 +6,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.LinkOption; import java.nio.file.StandardOpenOption; import java.security.MessageDigest; import java.time.Clock; @@ -28,11 +29,13 @@ public ConfigurationAuditLog(Path dataDirectory, Clock clock) throws IOException this.file = dataDirectory.resolve("configuration-audit.jsonl"); this.previousFile = dataDirectory.resolve("configuration-audit.jsonl.1"); this.clock = clock; - if (Files.isRegularFile(file)) { - if (Files.size(file) > MAX_BYTES + 64 * 1024) { + validateRetainedFile(previousFile); + if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) + throw new IOException("Configuration audit log is not a regular file"); + if (Files.size(file) > MAX_BYTES + 64 * 1024) throw new IOException("Configuration audit log exceeds its bounded size"); - } - previousHash = validateExisting(); + previousHash = validateExisting(file); } } @@ -64,9 +67,18 @@ private void rotateIfNeeded() throws IOException { } } - private String validateExisting() throws IOException { + private void validateRetainedFile(Path retained) throws IOException { + if (!Files.exists(retained, LinkOption.NOFOLLOW_LINKS)) return; + if (!Files.isRegularFile(retained, LinkOption.NOFOLLOW_LINKS)) + throw new IOException("Retained configuration audit log is not a regular file"); + if (Files.size(retained) > MAX_BYTES + 64 * 1024) + throw new IOException("Retained configuration audit log exceeds its bounded size"); + validateExisting(retained); + } + + private String validateExisting(Path selected) throws IOException { String expectedPrevious = "GENESIS"; - for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { + for (String line : Files.readAllLines(selected, StandardCharsets.UTF_8)) { if (line.isBlank()) continue; try { JsonNode stored = json.readTree(line); diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 755951c5..7d5433b0 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -12,6 +12,7 @@ import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -24,6 +25,7 @@ public final class ConfigurationOperations { public static final String CAPABILITY = "config.proxy-routing.v1"; private static final int MAX_OPERATIONS = 1000; private static final Duration LEASE = Duration.ofMinutes(2); + private static final Duration ACTIVE_RETENTION = Duration.ofMinutes(15); private static final Duration RETENTION = Duration.ofHours(24); private final NodeRegistry registry; @@ -176,8 +178,18 @@ private OperationView view(StoredOperation operation) { } private void prune() { - Instant cutoff = clock.instant().minus(RETENTION); - operations.entrySet().removeIf(entry -> entry.getValue().complete() && entry.getValue().createdAt.isBefore(cutoff)); + Instant now = clock.instant(); + Instant completedCutoff = now.minus(RETENTION); + Instant activeCutoff = now.minus(ACTIVE_RETENTION); + Iterator> iterator = operations.entrySet().iterator(); + while (iterator.hasNext()) { + StoredOperation operation = iterator.next().getValue(); + if (operation.createdAt.isBefore(operation.complete() ? completedCutoff : activeCutoff)) { + audit.append("OPERATION_EXPIRED", operation.id, null, + operation.complete() ? "RETAINED" : "ABANDONED"); + iterator.remove(); + } + } } private static ValidationException invalid(String detail) { diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 75975586..456f03e6 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -156,7 +156,12 @@ async function loadNodes() { } const visibleIds = new Set(body.items.filter(node => node.online && node.acceptedCapabilities.includes('config.proxy-routing.v1')) .map(node => node.nodeId)); - selectedNodes = new Set([...selectedNodes].filter(node => visibleIds.has(node))); + const filteredSelection = new Set([...selectedNodes].filter(node => visibleIds.has(node))); + if (filteredSelection.size !== selectedNodes.size) { + approvedPreview = null; + text(operationStatus, 'The selected proxies changed during refresh. Preview again before apply.'); + } + selectedNodes = filteredSelection; updateConfigurationButtons(); const first = body.items.length === 0 ? 0 : pageOffset + 1; const last = pageOffset + body.items.length; diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 0f7ebdbd..e87fe980 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -14,6 +14,7 @@ import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; +import java.time.ZoneId; import java.util.List; import java.util.Set; import java.util.UUID; @@ -62,4 +63,42 @@ class ConfigurationOperationsTest { Files.writeString(file, Files.readString(file).replaceFirst("TEST", "EDIT")); assertThrows(java.io.IOException.class, () -> new ConfigurationAuditLog(directory, clock)); } + + @Test void abandonedOperationsExpireAndCannotConsumeTheBoundForever() throws Exception { + MutableClock clock = new MutableClock(Instant.parse("2026-08-25T00:00:00Z")); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofHours(1)); + registry.register(new NodeRegistration("proxy-a", UUID.randomUUID(), "Proxy A", "VELOCITY", "test", 1, + Set.of("presence.snapshot", ConfigurationOperations.CAPABILITY), Set.of("presence.snapshot"))); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + UUID abandoned = operations.createRead(List.of("proxy-a")).operationId(); + + clock.advance(Duration.ofMinutes(16)); + + assertEquals("OPERATION_NOT_FOUND", + assertThrows(ValidationException.class, () -> operations.get(abandoned)).code()); + } + + @Test void retainedAuditSegmentIsVerifiedBeforeStartup() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + Path active = directory.resolve("configuration-audit.jsonl"); + Path retained = directory.resolve("configuration-audit.jsonl.1"); + ConfigurationAuditLog first = new ConfigurationAuditLog(directory, clock); + first.append("TEST", UUID.randomUUID(), "proxy-a", "OK"); + Files.move(active, retained); + ConfigurationAuditLog second = new ConfigurationAuditLog(directory, clock); + second.append("TEST", UUID.randomUUID(), "proxy-b", "OK"); + Files.writeString(retained, Files.readString(retained).replaceFirst("TEST", "EDIT")); + + assertThrows(java.io.IOException.class, () -> new ConfigurationAuditLog(directory, clock)); + } + + private static final class MutableClock extends Clock { + private Instant instant; + private MutableClock(Instant instant) { this.instant = instant; } + private void advance(Duration duration) { instant = instant.plus(duration); } + @Override public ZoneId getZone() { return ZoneOffset.UTC; } + @Override public Clock withZone(ZoneId zone) { return this; } + @Override public Instant instant() { return instant; } + } } diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index f1a24a7c..df4c125b 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -63,6 +63,7 @@ class ControlHttpServerTest { assertEquals(200, script.statusCode()); assertTrue(script.body().contains("offset=${pageOffset}&limit=${PAGE_SIZE}")); assertTrue(script.body().contains("nextPage.addEventListener")); + assertTrue(script.body().contains("filteredSelection.size !== selectedNodes.size")); assertEquals(200, get("/app.css", null).statusCode()); assertError(send("POST", "/", null, null), 405, "METHOD_NOT_ALLOWED"); assertEquals(200, get("/api/v1/health", null).statusCode()); From fd08eed4f22e623797880c100110cbc7f156165e Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 24 Aug 2026 20:47:31 -0600 Subject: [PATCH 11/42] Bind approvals and tasks to active sessions --- .../domain/ConfigurationOperations.java | 12 +++++-- .../control/domain/InMemoryNodeRegistry.java | 18 +++++++++++ .../control/domain/NodeRegistry.java | 1 + .../control/http/ControlHttpServer.java | 8 ++--- src/main/resources/web/app.js | 3 ++ .../domain/ConfigurationOperationsTest.java | 32 +++++++++++++++++-- .../control/http/ControlHttpServerTest.java | 3 ++ 7 files changed, 67 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 7d5433b0..c53f3e42 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -86,7 +86,11 @@ public synchronized OperationView get(UUID id) { return view(operation); } - public synchronized ConfigurationTask claim(String nodeId) { + public synchronized ConfigurationTask claim(String nodeId, UUID sessionId) { + return registry.withSession(nodeId, sessionId, () -> claimCurrentSession(nodeId)); + } + + private ConfigurationTask claimCurrentSession(String nodeId) { prune(); Instant now = clock.instant(); for (StoredOperation operation : operations.values()) { @@ -111,11 +115,15 @@ public synchronized ConfigurationTask claim(String nodeId) { } public synchronized OperationView complete(UUID operationId, String nodeId, ConfigurationTaskResult result) { + validateResult(result); + return registry.withSession(nodeId, result.sessionId(), () -> completeCurrentSession(operationId, nodeId, result)); + } + + private OperationView completeCurrentSession(UUID operationId, String nodeId, ConfigurationTaskResult result) { StoredOperation operation = operations.get(operationId); if (operation == null || !operation.states.containsKey(nodeId)) { throw new ValidationException("OPERATION_NOT_FOUND", "Operation task was not found", List.of()); } - validateResult(result); if ("COMPLETE".equals(operation.states.get(nodeId))) return view(operation); if (!"IN_PROGRESS".equals(operation.states.get(nodeId))) { throw new ValidationException("TASK_NOT_CLAIMED", "Operation task must be claimed before completion", List.of()); diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java index a043e684..dd2e4a3e 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -144,6 +144,24 @@ public void requireSession(String nodeId, UUID sessionId) { requireSession(node, sessionId); } + /** Runs one task mutation while replacement registration for this node is excluded. */ + @Override + public T withSession(String nodeId, UUID sessionId, java.util.function.Supplier action) { + validateId(nodeId, "nodeId"); + validateSession(sessionId); + Objects.requireNonNull(action, "action"); + AtomicBoolean found = new AtomicBoolean(); + AtomicReference result = new AtomicReference<>(); + nodes.computeIfPresent(nodeId, (ignored, existing) -> { + found.set(true); + requireSession(existing, sessionId); + result.set(action.get()); + return existing; + }); + if (!found.get()) throw new ValidationException("NODE_NOT_FOUND", "Node is not registered", List.of()); + return result.get(); + } + private NodeStatus view(StoredNode node) { // The exact boundary is offline: lastSeen + timeout must be strictly after now. boolean online = clock.instant().isBefore(node.lastSeen.plus(offlineTimeout)); diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java index 4567c781..1dd96ecc 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java @@ -13,6 +13,7 @@ public interface NodeRegistry { List list(int offset, int limit); NodeStatus find(String nodeId); void requireSession(String nodeId, java.util.UUID sessionId); + T withSession(String nodeId, java.util.UUID sessionId, java.util.function.Supplier action); record RegistrationResult(NodeStatus node, boolean created) { } record SnapshotResult(NodeStatus node, boolean applied) { } diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index cc93c874..2e2c0d96 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -200,12 +200,10 @@ private void route(HttpExchange exchange) throws IOException { } if (AUTH_LOGIN.equals(path)) { requireMethod(exchange, "POST"); - if (!authLimiter.allowAttempt()) throw new AuthenticationException(true); PasswordRequest request = read(exchange, PasswordRequest.class); String credentialRevision = request == null ? null : credentials.authenticateWebPassword(request.password()); if (credentialRevision == null) { - authLimiter.recordFailure(); - throw new AuthenticationException(false); + authenticationFailed(); } webSessions.remove(cookie(exchange, SESSION_COOKIE)); WebSessionStore.Session session = webSessions.create(credentialRevision); @@ -307,8 +305,7 @@ private void route(HttpExchange exchange) throws IOException { requireMethod(exchange, "POST"); authenticateNode(exchange, nodeId); ConfigurationRequests.Claim claim = read(exchange, ConfigurationRequests.Claim.class); - registry.requireSession(nodeId, claim.sessionId()); - ConfigurationTask task = configurationOperations.claim(nodeId); + ConfigurationTask task = configurationOperations.claim(nodeId, claim.sessionId()); if (task == null) { noContent(exchange); } else { @@ -322,7 +319,6 @@ private void route(HttpExchange exchange) throws IOException { requireMethod(exchange, "POST"); authenticateNode(exchange, nodeId); ConfigurationTaskResult result = read(exchange, ConfigurationTaskResult.class); - registry.requireSession(nodeId, result.sessionId()); send(exchange, 200, configurationOperations.complete(UUID.fromString(segments[2]), nodeId, result)); return; } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 456f03e6..b93eca26 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -192,6 +192,8 @@ form.addEventListener('submit', async event => { if (!response.ok) throw new Error(body?.error?.message || 'Authentication failed.'); authenticated = true; csrfToken = body.csrfToken; + approvedPreview = null; + selectedNodes.clear(); logout.hidden = false; pageOffset = 0; await loadNodes(); @@ -204,6 +206,7 @@ logout.addEventListener('click', async () => { try { await authorized('/api/v1/auth/logout', {method: 'POST'}); } catch (_) { /* Expired is logged out too. */ } authenticated = false; csrfToken = ''; + approvedPreview = null; logout.hidden = true; selectedNodes.clear(); nodes.replaceChildren(); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index e87fe980..99d0a967 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -34,7 +34,7 @@ class ConfigurationOperationsTest { ProxyRoutingConfiguration proposal = new ProxyRoutingConfiguration(true, List.of("lobby")); ConfigurationOperations.OperationView preview = operations.createPreview(List.of("proxy-a"), proposal); - ConfigurationTask previewTask = operations.claim("proxy-a"); + ConfigurationTask previewTask = operations.claim("proxy-a", registry.find("proxy-a").sessionId()); assertEquals("PREVIEW", previewTask.type()); preview = operations.complete(preview.operationId(), "proxy-a", new ConfigurationTaskResult(registry.find("proxy-a").sessionId(), true, "OK", "valid", "a".repeat(64), proposal, @@ -45,7 +45,7 @@ class ConfigurationOperationsTest { UUID previewId = preview.operationId(); String approvalToken = preview.approvalToken(); ConfigurationOperations.OperationView apply = operations.createApply(previewId, approvalToken); - ConfigurationTask applyTask = operations.claim("proxy-a"); + ConfigurationTask applyTask = operations.claim("proxy-a", registry.find("proxy-a").sessionId()); assertEquals("APPLY", applyTask.type()); assertEquals("a".repeat(64), applyTask.expectedRevision()); assertThrows(ValidationException.class, @@ -79,6 +79,29 @@ class ConfigurationOperationsTest { assertThrows(ValidationException.class, () -> operations.get(abandoned)).code()); } + @Test void replacementSessionExcludesOldClaimsAndCompletions() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID first = UUID.randomUUID(); + UUID second = UUID.randomUUID(); + UUID third = UUID.randomUUID(); + registry.register(registration(first)); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + ConfigurationOperations.OperationView operation = operations.createRead(List.of("proxy-a")); + + registry.register(registration(second)); + assertEquals("SESSION_MISMATCH", + assertThrows(ValidationException.class, () -> operations.claim("proxy-a", first)).code()); + operations.claim("proxy-a", second); + registry.register(registration(third)); + ProxyRoutingConfiguration configuration = new ProxyRoutingConfiguration(false, List.of()); + ConfigurationTaskResult stale = new ConfigurationTaskResult(second, true, "OK", "read", "b".repeat(64), + configuration, List.of(), false, false); + assertEquals("SESSION_MISMATCH", assertThrows(ValidationException.class, + () -> operations.complete(operation.operationId(), "proxy-a", stale)).code()); + } + @Test void retainedAuditSegmentIsVerifiedBeforeStartup() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); Path active = directory.resolve("configuration-audit.jsonl"); @@ -101,4 +124,9 @@ private static final class MutableClock extends Clock { @Override public Clock withZone(ZoneId zone) { return this; } @Override public Instant instant() { return instant; } } + + private static NodeRegistration registration(UUID sessionId) { + return new NodeRegistration("proxy-a", sessionId, "Proxy A", "VELOCITY", "test", 1, + Set.of("presence.snapshot", ConfigurationOperations.CAPABILITY), Set.of("presence.snapshot")); + } } diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index df4c125b..a01179b9 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -152,6 +152,9 @@ class ControlHttpServerTest { assertError(get("/api/v1/nodes", "one-too-many"), 429, "AUTH_RATE_LIMITED"); assertEquals(200, get("/api/v1/nodes", adminToken).statusCode()); assertEquals(201, send("POST", "/api/v1/nodes/register", registration(), nodeToken).statusCode()); + credentials.setWebPassword("valid-owner-password".toCharArray()); + assertEquals(200, send("POST", "/api/v1/auth/login", + "{\"password\":\"valid-owner-password\"}", null).statusCode()); } @Test void webPasswordCreatesHttpOnlySessionRequiresCsrfAndLogsOut() throws Exception { From 175833c2246c28283be1d9bdf3fa569616c48de4 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 24 Aug 2026 20:56:03 -0600 Subject: [PATCH 12/42] Protect previews and checkpoint audit tails --- README.md | 3 +- .../control/domain/ConfigurationAuditLog.java | 109 +++++++++++++++--- .../domain/ConfigurationOperations.java | 2 +- src/main/resources/web/app.js | 13 ++- .../domain/ConfigurationOperationsTest.java | 41 ++++++- .../control/http/ControlHttpServerTest.java | 1 + 6 files changed, 144 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 9bf04116..0233760d 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,8 @@ network-wide success. Proxies create a local backup, require atomic replacement the backup if reload fails. `configuration-audit.jsonl` records bounded, append-only, hash-chained operation metadata and rotates once at 5 MiB. Both -the active and retained segment are verified before startup accepts new operations. It does not contain configuration +the active and retained segment are verified against a durable atomic tail/count checkpoint before startup accepts new +operations, so record-boundary truncation also fails closed. It does not contain configuration values, credentials, or approval tokens. Operation queues are bounded; abandoned operations expire after 15 minutes and completed operations after 24 hours. Active operations are intentionally lost on Control restart and must be previewed again. diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java index 0fcc1548..1fa4a3d4 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java @@ -8,6 +8,7 @@ import java.nio.file.Path; import java.nio.file.LinkOption; import java.nio.file.StandardOpenOption; +import java.nio.file.StandardCopyOption; import java.security.MessageDigest; import java.time.Clock; import java.util.HexFormat; @@ -20,22 +21,38 @@ public final class ConfigurationAuditLog { private static final long MAX_BYTES = 5L * 1024 * 1024; private final Path file; private final Path previousFile; + private final Path checkpointFile; private final Clock clock; + private final long maxBytes; private final ObjectMapper json = new ObjectMapper(); private String previousHash = "GENESIS"; + private long activeRecords; + private String retainedHash = "GENESIS"; + private long retainedRecords; public ConfigurationAuditLog(Path dataDirectory, Clock clock) throws IOException { + this(dataDirectory, clock, MAX_BYTES); + } + + ConfigurationAuditLog(Path dataDirectory, Clock clock, long maxBytes) throws IOException { + if (maxBytes < 1) throw new IllegalArgumentException("maxBytes must be positive"); Files.createDirectories(dataDirectory); this.file = dataDirectory.resolve("configuration-audit.jsonl"); this.previousFile = dataDirectory.resolve("configuration-audit.jsonl.1"); + this.checkpointFile = dataDirectory.resolve("configuration-audit.checkpoint"); this.clock = clock; - validateRetainedFile(previousFile); - if (Files.exists(file, LinkOption.NOFOLLOW_LINKS)) { - if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) - throw new IOException("Configuration audit log is not a regular file"); - if (Files.size(file) > MAX_BYTES + 64 * 1024) - throw new IOException("Configuration audit log exceeds its bounded size"); - previousHash = validateExisting(file); + this.maxBytes = maxBytes; + SegmentState retained = validateOptional(previousFile, "Retained configuration audit log"); + SegmentState active = validateOptional(file, "Configuration audit log"); + retainedHash = retained.tailHash(); + retainedRecords = retained.records(); + previousHash = active.tailHash(); + activeRecords = active.records(); + boolean hasSegments = retained.present() || active.present(); + if (hasSegments) { + validateCheckpoint(); + } else if (Files.exists(checkpointFile, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Configuration audit checkpoint has no audit segments"); } } @@ -55,29 +72,35 @@ public synchronized void append(String action, UUID operationId, String nodeId, Files.writeString(file, json.writeValueAsString(core) + System.lineSeparator(), StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE); previousHash = hash; + activeRecords++; + writeCheckpoint(); } catch (Exception e) { throw new AuditException(e); } } private void rotateIfNeeded() throws IOException { - if (Files.exists(file) && Files.size(file) >= MAX_BYTES) { + if (Files.exists(file) && Files.size(file) >= maxBytes) { Files.move(file, previousFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + retainedHash = previousHash; + retainedRecords = activeRecords; previousHash = "GENESIS"; + activeRecords = 0; } } - private void validateRetainedFile(Path retained) throws IOException { - if (!Files.exists(retained, LinkOption.NOFOLLOW_LINKS)) return; - if (!Files.isRegularFile(retained, LinkOption.NOFOLLOW_LINKS)) - throw new IOException("Retained configuration audit log is not a regular file"); - if (Files.size(retained) > MAX_BYTES + 64 * 1024) - throw new IOException("Retained configuration audit log exceeds its bounded size"); - validateExisting(retained); + private SegmentState validateOptional(Path selected, String label) throws IOException { + if (!Files.exists(selected, LinkOption.NOFOLLOW_LINKS)) return new SegmentState(false, "GENESIS", 0); + if (!Files.isRegularFile(selected, LinkOption.NOFOLLOW_LINKS)) + throw new IOException(label + " is not a regular file"); + if (Files.size(selected) > maxBytes + 64 * 1024) + throw new IOException(label + " exceeds its bounded size"); + return validateExisting(selected); } - private String validateExisting(Path selected) throws IOException { + private SegmentState validateExisting(Path selected) throws IOException { String expectedPrevious = "GENESIS"; + long records = 0; for (String line : Files.readAllLines(selected, StandardCharsets.UTF_8)) { if (line.isBlank()) continue; try { @@ -101,15 +124,67 @@ private String validateExisting(Path selected) throws IOException { throw new IOException("Configuration audit chain is invalid"); } expectedPrevious = calculated; + records++; } catch (IOException e) { throw e; } catch (Exception e) { throw new IOException("Configuration audit chain is invalid", e); } } - return expectedPrevious; + return new SegmentState(true, expectedPrevious, records); + } + + private void validateCheckpoint() throws IOException { + if (!Files.isRegularFile(checkpointFile, LinkOption.NOFOLLOW_LINKS) + || Files.size(checkpointFile) > 4096) { + throw new IOException("Configuration audit checkpoint is missing or invalid"); + } + try { + JsonNode checkpoint = json.readTree(Files.readAllBytes(checkpointFile)); + if (checkpoint == null || !checkpoint.isObject() || checkpoint.size() != 4 + || !checkpoint.path("activeHash").asText().equals(previousHash) + || !checkpoint.path("retainedHash").asText().equals(retainedHash) + || !checkpoint.path("activeRecords").isIntegralNumber() + || !checkpoint.path("retainedRecords").isIntegralNumber() + || !checkpoint.path("activeRecords").canConvertToLong() + || !checkpoint.path("retainedRecords").canConvertToLong() + || checkpoint.path("activeRecords").asLong() != activeRecords + || checkpoint.path("retainedRecords").asLong() != retainedRecords) { + throw new IOException("Configuration audit checkpoint does not match the audit chain"); + } + } catch (IOException e) { + throw e; + } catch (RuntimeException e) { + throw new IOException("Configuration audit checkpoint is invalid", e); + } } + private void writeCheckpoint() throws IOException { + Map checkpoint = new LinkedHashMap<>(); + checkpoint.put("activeHash", previousHash); + checkpoint.put("activeRecords", activeRecords); + checkpoint.put("retainedHash", retainedHash); + checkpoint.put("retainedRecords", retainedRecords); + Path temporary = Files.createTempFile(checkpointFile.getParent(), "configuration-audit-", ".checkpoint"); + try { + Files.write(temporary, json.writeValueAsBytes(checkpoint), StandardOpenOption.TRUNCATE_EXISTING); + try (java.nio.channels.FileChannel channel = java.nio.channels.FileChannel.open(temporary, + StandardOpenOption.WRITE)) { + channel.force(true); + } + try { + Files.move(temporary, checkpointFile, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + Files.move(temporary, checkpointFile, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporary); + } + } + + private record SegmentState(boolean present, String tailHash, long records) { } + @SuppressWarnings("serial") public static final class AuditException extends RuntimeException { private AuditException(Throwable cause) { super(cause); } diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index c53f3e42..26aa58a3 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -64,11 +64,11 @@ public synchronized OperationView createApply(UUID previewId, String approvalTok approvalToken.getBytes(java.nio.charset.StandardCharsets.UTF_8))) { throw new ValidationException("APPROVAL_REQUIRED", "A valid unused preview approval is required", List.of()); } - preview.approvalUsed = true; Map revisions = new LinkedHashMap<>(); preview.results.forEach((node, result) -> revisions.put(node, result.revision())); StoredOperation apply = store("APPLY", new ArrayList<>(preview.states.keySet()), preview.configuration, null, revisions); + preview.approvalUsed = true; try { audit.append("APPLY_APPROVED", apply.id, null, "QUEUED"); } catch (RuntimeException e) { diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index b93eca26..4a372ced 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -22,6 +22,7 @@ let csrfToken = ''; let pageOffset = 0; let selectedNodes = new Set(); let approvedPreview = null; +let inputGeneration = 0; function text(element, value) { element.textContent = value; @@ -64,6 +65,7 @@ function nodeCard(node) { checkbox.addEventListener('change', () => { if (checkbox.checked) selectedNodes.add(node.nodeId); else selectedNodes.delete(node.nodeId); approvedPreview = null; + inputGeneration++; updateConfigurationButtons(); }); selector.append(checkbox, title); @@ -159,6 +161,7 @@ async function loadNodes() { const filteredSelection = new Set([...selectedNodes].filter(node => visibleIds.has(node))); if (filteredSelection.size !== selectedNodes.size) { approvedPreview = null; + inputGeneration++; text(operationStatus, 'The selected proxies changed during refresh. Preview again before apply.'); } selectedNodes = filteredSelection; @@ -194,6 +197,7 @@ form.addEventListener('submit', async event => { csrfToken = body.csrfToken; approvedPreview = null; selectedNodes.clear(); + inputGeneration++; logout.hidden = false; pageOffset = 0; await loadNodes(); @@ -207,6 +211,7 @@ logout.addEventListener('click', async () => { authenticated = false; csrfToken = ''; approvedPreview = null; + inputGeneration++; logout.hidden = true; selectedNodes.clear(); nodes.replaceChildren(); @@ -243,13 +248,17 @@ readConfiguration.addEventListener('click', async () => { previewConfiguration.addEventListener('click', async () => { approvedPreview = null; + const previewGeneration = inputGeneration; try { const operation = await startConfigurationOperation('/api/v1/configuration/preview', { nodeIds: [...selectedNodes], configuration: proposal() }); - if (operation.state === 'SUCCEEDED' && operation.approvalToken) { + if (operation.state === 'SUCCEEDED' && operation.approvalToken + && previewGeneration === inputGeneration) { approvedPreview = {operationId: operation.operationId, approvalToken: operation.approvalToken}; updateConfigurationButtons(); + } else if (previewGeneration !== inputGeneration) { + text(operationStatus, 'The targets or proposal changed while previewing. Preview again before apply.'); } } catch (error) { text(operationStatus, error.message); } }); @@ -258,6 +267,7 @@ applyConfiguration.addEventListener('click', async () => { if (!approvedPreview || !window.confirm('Apply this exact preview to every selected proxy? Each node may still reject a stale revision.')) return; const approval = approvedPreview; approvedPreview = null; + inputGeneration++; try { await startConfigurationOperation('/api/v1/configuration/apply', { previewOperationId: approval.operationId, approvalToken: approval.approvalToken @@ -267,6 +277,7 @@ applyConfiguration.addEventListener('click', async () => { [sendAll, blockedServers].forEach(field => field.addEventListener('input', () => { if (approvedPreview) text(operationStatus, 'The proposal changed. Preview it again before apply.'); approvedPreview = null; + inputGeneration++; updateConfigurationButtons(); })); refresh.addEventListener('click', loadNodes); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 99d0a967..511cdab1 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -104,18 +104,49 @@ class ConfigurationOperationsTest { @Test void retainedAuditSegmentIsVerifiedBeforeStartup() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); - Path active = directory.resolve("configuration-audit.jsonl"); Path retained = directory.resolve("configuration-audit.jsonl.1"); - ConfigurationAuditLog first = new ConfigurationAuditLog(directory, clock); + ConfigurationAuditLog first = new ConfigurationAuditLog(directory, clock, 1); first.append("TEST", UUID.randomUUID(), "proxy-a", "OK"); - Files.move(active, retained); - ConfigurationAuditLog second = new ConfigurationAuditLog(directory, clock); - second.append("TEST", UUID.randomUUID(), "proxy-b", "OK"); + first.append("TEST", UUID.randomUUID(), "proxy-b", "OK"); Files.writeString(retained, Files.readString(retained).replaceFirst("TEST", "EDIT")); assertThrows(java.io.IOException.class, () -> new ConfigurationAuditLog(directory, clock)); } + @Test void auditCheckpointDetectsRecordBoundaryTruncation() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + Path active = directory.resolve("configuration-audit.jsonl"); + ConfigurationAuditLog log = new ConfigurationAuditLog(directory, clock); + log.append("FIRST", UUID.randomUUID(), "proxy-a", "OK"); + log.append("SECOND", UUID.randomUUID(), "proxy-a", "OK"); + List records = Files.readAllLines(active); + Files.writeString(active, records.get(0) + System.lineSeparator()); + + assertThrows(java.io.IOException.class, () -> new ConfigurationAuditLog(directory, clock)); + } + + @Test void operationLimitDoesNotConsumePreviewApproval() throws Exception { + MutableClock clock = new MutableClock(Instant.parse("2026-08-25T00:00:00Z")); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofHours(1)); + registry.register(registration(UUID.randomUUID())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + ProxyRoutingConfiguration proposal = new ProxyRoutingConfiguration(true, List.of()); + ConfigurationOperations.OperationView preview = operations.createPreview(List.of("proxy-a"), proposal); + operations.claim("proxy-a", registry.find("proxy-a").sessionId()); + preview = operations.complete(preview.operationId(), "proxy-a", + new ConfigurationTaskResult(registry.find("proxy-a").sessionId(), true, "OK", "valid", + "c".repeat(64), proposal, List.of(), false, false)); + for (int i = 0; i < 999; i++) operations.createRead(List.of("proxy-a")); + + UUID previewId = preview.operationId(); + String approval = preview.approvalToken(); + assertEquals("OPERATION_LIMIT", assertThrows(ValidationException.class, + () -> operations.createApply(previewId, approval)).code()); + clock.advance(Duration.ofMinutes(16)); + assertEquals("APPLY", operations.createApply(previewId, approval).type()); + } + private static final class MutableClock extends Clock { private Instant instant; private MutableClock(Instant instant) { this.instant = instant; } diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index a01179b9..a6842a16 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -64,6 +64,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("offset=${pageOffset}&limit=${PAGE_SIZE}")); assertTrue(script.body().contains("nextPage.addEventListener")); assertTrue(script.body().contains("filteredSelection.size !== selectedNodes.size")); + assertTrue(script.body().contains("previewGeneration === inputGeneration")); assertEquals(200, get("/app.css", null).statusCode()); assertError(send("POST", "/", null, null), 405, "METHOD_NOT_ALLOWED"); assertEquals(200, get("/api/v1/health", null).statusCode()); From 1abe6da75577fc60a1110df08e1687e5e25847ba Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 25 Aug 2026 08:43:16 -0600 Subject: [PATCH 13/42] Add full backend configuration control --- README.md | 46 +++-- .../control/domain/ConfigurationAuditLog.java | 48 +++-- .../domain/ConfigurationOperations.java | 40 +++-- .../control/domain/InMemoryNodeRegistry.java | 7 +- .../control/http/ControlHttpServer.java | 37 +++- .../protocol/ConfigurationRequests.java | 8 +- .../control/protocol/ConfigurationTask.java | 2 +- .../protocol/ConfigurationTaskResult.java | 9 +- .../protocol/ManagedConfiguration.java | 71 ++++++++ src/main/resources/web/app.css | 10 +- src/main/resources/web/app.js | 167 ++++++++++++++++-- src/main/resources/web/index.html | 58 +++++- .../domain/ConfigurationOperationsTest.java | 36 ++++ .../control/http/ControlHttpServerTest.java | 6 + 14 files changed, 477 insertions(+), 68 deletions(-) create mode 100644 src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java diff --git a/README.md b/README.md index 0233760d..72cdd391 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # VotingPlugin Control VotingPlugin Control is a separate, local-first administration service for a VotingPlugin network. The current milestone -provides authenticated discovery of multiple BungeeCord and Velocity proxies and a bounded proxy-routing configuration -workflow. It includes a local WebUI over the same versioned API. It does not process votes, and VotingPlugin +provides authenticated discovery of multiple BungeeCord and Velocity proxies, direct Bukkit backend enrollment, full +VotingPlugin YAML configuration control, and common quick-setup workflows. It includes a local WebUI over the same +versioned API. It does not process votes, and VotingPlugin does not depend on it for startup, joins, routing, or shutdown. ## Trust and deployment boundary @@ -10,10 +11,11 @@ does not depend on it for startup, joins, routing, or shutdown. One Control process can observe an entire network: ```text -Browser -> Control WebUI/API <- Proxy A / Proxy B / Proxy C <- backend servers +Browser -> Control WebUI/API <- Proxy A / Proxy B / Proxy C + ^--- Bukkit backend A / B / C ``` -Proxy connectors initiate outbound HTTP(S) requests. Bukkit backend nodes do not connect directly. Control works without +Proxy and Bukkit connectors initiate outbound HTTP(S) requests. Control works without Internet or a cloud account. Node calls require explicitly created, node-bound bearer credentials; management reads require a separate admin credential. Browser management uses a distinct owner-chosen password. Raw credentials are never stored by Control: `data/credentials.json` contains SHA-256 verifiers for 256-bit random tokens and a salted, @@ -45,8 +47,8 @@ report `development` rather than maintaining a second version literal. | `CONTROL_REQUEST_TIMEOUT_SECONDS` | `10` | `1`–`60`; bounds stalled JDK HTTP exchanges | | `CONTROL_SECURE_COOKIE` | `false` | Set `true` only when browsers reach Control through a trusted HTTPS reverse proxy | -The server also uses a bounded HTTP executor (8 active requests and a 32-request queue), a 64 KiB request limit, bounded -JSON depth/string/number sizes, and a bounded invalid-authentication failure limit. Valid enrolled/admin credentials remain +The server also uses a bounded HTTP executor (8 active requests and a 32-request queue), a 768 KiB request limit, bounded +JSON depth/string/number sizes, a two-slot password-verification semaphore, and a bounded invalid-authentication failure limit. Valid enrolled/admin credentials remain usable even while invalid traffic is throttled. Shutdown stops the server and its daemon request workers without waiting indefinitely. @@ -72,6 +74,9 @@ handle the output as a password. # Create or rotate a credential bound to proxy-a java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar enroll proxy-a data +# Enroll every Bukkit backend separately when full file control is wanted +java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar enroll backend-lobby data + # Immediately revoke it java -jar target/votingplugin-control-0.1.0-SNAPSHOT-all.jar revoke proxy-a data @@ -108,7 +113,7 @@ All errors have the stable form: | `POST` | `/api/v1/auth/login` | WebUI password | Create a bounded browser session and return its CSRF token | | `GET` | `/api/v1/auth/session` | WebUI session | Restore the in-memory CSRF token after a page refresh; invalid/absent sessions return `204` | | `POST` | `/api/v1/auth/logout` | WebUI session + CSRF | Revoke the current browser session and expire its cookie | -| `POST` | `/api/v1/nodes/register` | node | Idempotently create/replace one enrolled proxy session | +| `POST` | `/api/v1/nodes/register` | node | Idempotently create/replace one enrolled proxy or Bukkit session | | `PUT` | `/api/v1/nodes/{nodeId}/heartbeat` | matching node | Refresh liveness and replace capability advertisement | | `PUT` | `/api/v1/nodes/{nodeId}/presence` | matching node | Replace that session's backend snapshot | | `POST` | `/api/v1/nodes/{nodeId}/operations` | matching node | Claim one queued configuration task, or `204` | @@ -127,14 +132,21 @@ clears its old topology. Presence snapshots are full replacements, contain at mo monotonic sequence within the registered session. Replayed or out-of-order sequence numbers are idempotently ignored. Control records its own observation time; it does not trust remote wall-clock time for online/offline decisions. -Configuration control is deliberately not a YAML editor. Protocol capability `config.proxy-routing.v1` exposes only -`sendVotesToAllServers` and `blockedServers`; credentials, database settings, plugin channels, and unknown keys never enter -the API. Preview validates each node without writing and returns its current revision and typed changes. Apply consumes a +Configuration is split into independently negotiated capabilities. `config.proxy-routing.v1` exposes typed proxy routing. +`config.files.v1` manages `Config.yml`, `VoteSites.yml`, `SpecialRewards.yml`, `GUI.yml`, `Shop.yml`, and +`BungeeSettings.yml` on enrolled Bukkit nodes through a bounded YAML editor. `config.quick-setup.v1` supplies standalone, +proxy-backend, and vote-site presets. Password, secret, token, API-key, authorization, and webhook-secret values are masked +on every read; leaving the redaction marker in a proposal preserves the current value. A newly entered secret is accepted +only in the authenticated proposal and is not returned in results or written to the audit log. + +Preview parses YAML and calculates path-level changes without writing. Apply consumes a single-use random approval token, carries each previewed revision to that node, and reports partial failures instead of a -network-wide success. Proxies create a local backup, require atomic replacement of the configuration file, soft-reload, and restore -the backup if reload fails. +network-wide success. Proxies and Bukkit nodes create local backups, require atomic replacement, reload, and restore the +backup if reload fails. Bukkit reads normalize YAML; administrators should expect comments and formatting to be normalized +when applying through the full editor. -`configuration-audit.jsonl` records bounded, append-only, hash-chained operation metadata and rotates once at 5 MiB. Both +`configuration-audit.jsonl` records bounded, append-only, hash-chained operation metadata and rotates once at 5 MiB. A +cross-process lifetime lock prevents two Control processes from forking the same audit chain. Both the active and retained segment are verified against a durable atomic tail/count checkpoint before startup accepts new operations, so record-boundary truncation also fails closed. It does not contain configuration values, credentials, or approval tokens. Operation queues are bounded; abandoned operations expire after 15 minutes and @@ -154,7 +166,7 @@ They are not misleadingly used by these simple HTTP resource endpoints. - `403 CSRF_REQUIRED`: refresh/sign in again and retry the WebUI write from the same session. - `409 SESSION_MISMATCH`: re-register; Control has a newer process session for that stable node. - `409 UNSUPPORTED_PROTOCOL` / `INCOMPATIBLE_CAPABILITIES`: upgrade the older side before retrying. -- Node stays offline: verify the proxy connector is enabled, the node ID matches enrollment, and heartbeat timeouts permit +- Node stays offline: verify its connector is enabled, the node ID matches enrollment, and heartbeat timeouts permit the configured interval. - LAN startup is refused: create the admin token or WebUI password first and protect transport with HTTPS/private tunneling. - Corrupt `instance-id` or credential data fails closed; restore a known backup or deliberately recreate the affected file @@ -162,7 +174,7 @@ They are not misleadingly used by these simple HTTP resource endpoints. ## Intentionally out of scope -This milestone has no unrestricted configuration editor, manual rollback endpoint, topology persistence/history, -diagnostics bundle, cloud relay, or remote-support sessions. Node and operation state is currently in memory, so proxies -automatically re-register after a Control restart and an interrupted change requires a new preview. Manual installation +This milestone has no arbitrary command execution, manual rollback endpoint, topology persistence/history, diagnostics +bundle, cloud relay, or remote-support sessions. Node and operation state is currently in memory, so nodes automatically +re-register after a Control restart and an interrupted change requires a new preview. Manual installation remains supported; the companion VotingPlugin development PR can also opt in to verified download and child-process hosting. diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java index 1fa4a3d4..e5d964cc 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java @@ -9,6 +9,8 @@ import java.nio.file.LinkOption; import java.nio.file.StandardOpenOption; import java.nio.file.StandardCopyOption; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; import java.security.MessageDigest; import java.time.Clock; import java.util.HexFormat; @@ -17,11 +19,13 @@ import java.util.UUID; /** Local append-only, hash-chained operation metadata. Values and approval tokens are never recorded. */ -public final class ConfigurationAuditLog { +public final class ConfigurationAuditLog implements AutoCloseable { private static final long MAX_BYTES = 5L * 1024 * 1024; private final Path file; private final Path previousFile; private final Path checkpointFile; + private final FileChannel lockChannel; + private final FileLock processLock; private final Clock clock; private final long maxBytes; private final ObjectMapper json = new ObjectMapper(); @@ -42,17 +46,31 @@ public ConfigurationAuditLog(Path dataDirectory, Clock clock) throws IOException this.checkpointFile = dataDirectory.resolve("configuration-audit.checkpoint"); this.clock = clock; this.maxBytes = maxBytes; - SegmentState retained = validateOptional(previousFile, "Retained configuration audit log"); - SegmentState active = validateOptional(file, "Configuration audit log"); - retainedHash = retained.tailHash(); - retainedRecords = retained.records(); - previousHash = active.tailHash(); - activeRecords = active.records(); - boolean hasSegments = retained.present() || active.present(); - if (hasSegments) { - validateCheckpoint(); - } else if (Files.exists(checkpointFile, LinkOption.NOFOLLOW_LINKS)) { - throw new IOException("Configuration audit checkpoint has no audit segments"); + FileChannel acquiredChannel = FileChannel.open(dataDirectory.resolve("configuration-audit.lock"), + StandardOpenOption.CREATE, StandardOpenOption.WRITE); + FileLock acquiredLock = null; + try { + acquiredLock = acquiredChannel.tryLock(); + if (acquiredLock == null) throw new IOException("Configuration audit is already in use"); + this.lockChannel = acquiredChannel; + this.processLock = acquiredLock; + SegmentState retained = validateOptional(previousFile, "Retained configuration audit log"); + SegmentState active = validateOptional(file, "Configuration audit log"); + retainedHash = retained.tailHash(); + retainedRecords = retained.records(); + previousHash = active.tailHash(); + activeRecords = active.records(); + boolean hasSegments = retained.present() || active.present(); + if (hasSegments) { + validateCheckpoint(); + } else if (Files.exists(checkpointFile, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("Configuration audit checkpoint has no audit segments"); + } + } catch (Exception e) { + if (acquiredLock != null) acquiredLock.close(); + acquiredChannel.close(); + if (e instanceof IOException io) throw io; + throw new IOException("Configuration audit is already in use", e); } } @@ -183,6 +201,12 @@ private void writeCheckpoint() throws IOException { } } + @Override + public synchronized void close() throws IOException { + processLock.close(); + lockChannel.close(); + } + private record SegmentState(boolean present, String tailHash, long records) { } @SuppressWarnings("serial") diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 26aa58a3..8aaeaf25 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -4,6 +4,7 @@ import com.bencodez.votingplugin.control.protocol.ConfigurationTaskResult; import com.bencodez.votingplugin.control.protocol.NodeStatus; import com.bencodez.votingplugin.control.protocol.ProxyRoutingConfiguration; +import com.bencodez.votingplugin.control.protocol.ManagedConfiguration; import java.security.MessageDigest; import java.security.SecureRandom; import java.time.Clock; @@ -21,8 +22,10 @@ import java.util.UUID; /** In-memory, bounded coordinator for outbound node configuration tasks. */ -public final class ConfigurationOperations { +public final class ConfigurationOperations implements AutoCloseable { public static final String CAPABILITY = "config.proxy-routing.v1"; + public static final String FILE_CAPABILITY = "config.files.v1"; + public static final String QUICK_SETUP_CAPABILITY = "config.quick-setup.v1"; private static final int MAX_OPERATIONS = 1000; private static final Duration LEASE = Duration.ofMinutes(2); private static final Duration ACTIVE_RETENTION = Duration.ofMinutes(15); @@ -41,14 +44,23 @@ public ConfigurationOperations(NodeRegistry registry, ConfigurationAuditLog audi } public synchronized OperationView createRead(List nodeIds) { - return create("READ", validateTargets(nodeIds), null, null); + return createRead(nodeIds, ManagedConfiguration.proxy(new ProxyRoutingConfiguration(false, List.of()))); } public synchronized OperationView createPreview(List nodeIds, ProxyRoutingConfiguration configuration) { + return createPreview(nodeIds, configuration == null ? null : ManagedConfiguration.proxy(configuration)); + } + + public synchronized OperationView createRead(List nodeIds, ManagedConfiguration selector) { + if (selector == null) selector = ManagedConfiguration.proxy(new ProxyRoutingConfiguration(false, List.of())); + return create("READ", validateTargets(nodeIds, selector.capability()), selector, null); + } + + public synchronized OperationView createPreview(List nodeIds, ManagedConfiguration configuration) { if (configuration == null) throw invalid("configuration is required"); byte[] token = new byte[32]; random.nextBytes(token); - return create("PREVIEW", validateTargets(nodeIds), configuration, + return create("PREVIEW", validateTargets(nodeIds, configuration.capability()), configuration, Base64.getUrlEncoder().withoutPadding().encodeToString(token)); } @@ -135,7 +147,7 @@ private OperationView completeCurrentSession(UUID operationId, String nodeId, Co return view(operation); } - private OperationView create(String type, List targets, ProxyRoutingConfiguration config, String token) { + private OperationView create(String type, List targets, ManagedConfiguration config, String token) { StoredOperation operation = store(type, targets, config, token, Map.of()); try { audit.append("OPERATION_CREATED", operation.id, null, type); @@ -146,7 +158,7 @@ private OperationView create(String type, List targets, ProxyRoutingConf return view(operation); } - private StoredOperation store(String type, List targets, ProxyRoutingConfiguration config, + private StoredOperation store(String type, List targets, ManagedConfiguration config, String token, Map revisions) { prune(); if (operations.size() >= MAX_OPERATIONS) throw new ValidationException("OPERATION_LIMIT", @@ -160,14 +172,14 @@ private StoredOperation store(String type, List targets, ProxyRoutingCon return result; } - private List validateTargets(List nodeIds) { + private List validateTargets(List nodeIds, String capability) { if (nodeIds == null || nodeIds.isEmpty() || nodeIds.size() > 100) throw invalid("nodeIds must contain 1 to 100 entries"); Set unique = new HashSet<>(); List result = new ArrayList<>(); for (String nodeId : nodeIds) { if (nodeId == null || !unique.add(nodeId)) throw invalid("nodeIds must be unique"); NodeStatus node = registry.find(nodeId); - if (node == null || !node.online() || !node.acceptedCapabilities().contains(CAPABILITY)) { + if (node == null || !node.online() || !node.acceptedCapabilities().contains(capability)) { throw new ValidationException("NODE_UNAVAILABLE", "Node cannot accept configuration operations", List.of(nodeId)); } result.add(nodeId); @@ -181,7 +193,8 @@ private OperationView view(StoredOperation operation) { String approval = "PREVIEW".equals(operation.type) && operation.complete() && operation.results.values().stream().allMatch(ConfigurationTaskResult::success) && !operation.approvalUsed ? operation.approvalToken : null; - return new OperationView(operation.id, operation.type, state, operation.createdAt, operation.configuration, + return new OperationView(operation.id, operation.type, state, operation.createdAt, + operation.configuration == null ? null : operation.configuration.publicView(), Map.copyOf(operation.states), Map.copyOf(operation.results), approval); } @@ -217,14 +230,19 @@ private static void validateResult(ConfigurationTaskResult result) { } } + @Override + public void close() throws java.io.IOException { + audit.close(); + } + public record OperationView(UUID operationId, String type, String state, Instant createdAt, - ProxyRoutingConfiguration configuration, Map nodeStates, + ManagedConfiguration configuration, Map nodeStates, Map results, String approvalToken) { } private static final class StoredOperation { private final UUID id; private final String type; - private final ProxyRoutingConfiguration configuration; + private final ManagedConfiguration configuration; private final String approvalToken; private final Instant createdAt; private final LinkedHashMap states; @@ -233,7 +251,7 @@ private static final class StoredOperation { private final LinkedHashMap expectedRevisions; private boolean approvalUsed; - private StoredOperation(UUID id, String type, ProxyRoutingConfiguration configuration, String approvalToken, + private StoredOperation(UUID id, String type, ManagedConfiguration configuration, String approvalToken, Instant createdAt, LinkedHashMap states, LinkedHashMap results, LinkedHashMap leasedAt, diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java index dd2e4a3e..bdb8c1fc 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -25,10 +25,11 @@ /** Thread-safe, deterministic current topology registry. State is intentionally in memory for this milestone. */ public final class InMemoryNodeRegistry implements NodeRegistry { public static final Set SUPPORTED_CAPABILITIES = Set.of("discovery.read", "presence.snapshot", - ConfigurationOperations.CAPABILITY); + ConfigurationOperations.CAPABILITY, ConfigurationOperations.FILE_CAPABILITY, + ConfigurationOperations.QUICK_SETUP_CAPABILITY); private static final Pattern ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final Pattern CAPABILITY = Pattern.compile("[a-z][a-z0-9.-]{0,63}"); - private static final Set PLATFORMS = Set.of("BUNGEECORD", "VELOCITY"); + private static final Set PLATFORMS = Set.of("BUNGEECORD", "VELOCITY", "BUKKIT"); private static final int MAX_CAPABILITIES = 64; private static final int MAX_BACKENDS = 4096; @@ -179,7 +180,7 @@ private static void validate(NodeRegistration registration) { requireText(registration.displayName(), "displayName", 100); requireText(registration.pluginVersion(), "pluginVersion", 40); if (registration.platform() == null || !PLATFORMS.contains(registration.platform())) { - throw invalid("platform must be BUNGEECORD or VELOCITY"); + throw invalid("platform must be BUNGEECORD, VELOCITY, or BUKKIT"); } validateProtocol(registration.protocolVersion()); validateCapabilities(registration.capabilities(), registration.requiredCapabilities()); diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 2e2c0d96..e1bb448b 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -10,6 +10,7 @@ import com.bencodez.votingplugin.control.protocol.ConfigurationTaskResult; import com.bencodez.votingplugin.control.protocol.ControlIdentity; import com.bencodez.votingplugin.control.protocol.Heartbeat; +import com.bencodez.votingplugin.control.protocol.ManagedConfiguration; import com.bencodez.votingplugin.control.protocol.NodeRegistration; import com.bencodez.votingplugin.control.protocol.NodeStatus; import com.bencodez.votingplugin.control.protocol.PresenceSnapshot; @@ -46,10 +47,11 @@ import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.Semaphore; /** Bounded HTTP adapter. Node writes are enrolled; node listings require the local admin credential. */ public final class ControlHttpServer implements AutoCloseable { - public static final int MAX_REQUEST_BYTES = 64 * 1024; + public static final int MAX_REQUEST_BYTES = 768 * 1024; private static final String HEALTH = "/api/v1/health"; private static final String NODES = "/api/v1/nodes"; private static final String REGISTER = "/api/v1/nodes/register"; @@ -76,6 +78,7 @@ public final class ControlHttpServer implements AutoCloseable { private final AuthFailureLimiter authLimiter; private final WebSessionStore webSessions; private final boolean secureCookies; + private final Semaphore passwordVerifications = new Semaphore(2, true); public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials) throws IOException { @@ -115,7 +118,8 @@ public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, Contr json.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); json.getFactory().enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION.mappedFeature()); json.getFactory().setStreamReadConstraints(StreamReadConstraints.builder() - .maxNestingDepth(20).maxStringLength(4096).maxNumberLength(64).build()); + .maxNestingDepth(20).maxStringLength(ManagedConfiguration.MAX_CONTENT + 4096) + .maxNumberLength(64).build()); authLimiter = new AuthFailureLimiter(nanoTime, MAX_AUTH_FAILURES_PER_MINUTE, TimeUnit.MINUTES.toNanos(1)); webSessions = new WebSessionStore(clock); server = HttpServer.create(address, 32); @@ -147,6 +151,11 @@ public void close() { } catch (InterruptedException e) { Thread.currentThread().interrupt(); } + try { + configurationOperations.close(); + } catch (IOException ignored) { + // The server is already stopped; startup validation will detect any later audit problem. + } } private void handle(HttpExchange exchange) throws IOException { @@ -201,7 +210,16 @@ private void route(HttpExchange exchange) throws IOException { if (AUTH_LOGIN.equals(path)) { requireMethod(exchange, "POST"); PasswordRequest request = read(exchange, PasswordRequest.class); - String credentialRevision = request == null ? null : credentials.authenticateWebPassword(request.password()); + requireRequest(request); + if (!passwordVerifications.tryAcquire()) { + throw new AuthenticationException(true); + } + String credentialRevision; + try { + credentialRevision = credentials.authenticateWebPassword(request.password()); + } finally { + passwordVerifications.release(); + } if (credentialRevision == null) { authenticationFailed(); } @@ -257,13 +275,15 @@ private void route(HttpExchange exchange) throws IOException { requireMethod(exchange, "POST"); authenticateAdmin(exchange, true); ConfigurationRequests.Read request = read(exchange, ConfigurationRequests.Read.class); - send(exchange, 202, configurationOperations.createRead(request.nodeIds())); + requireRequest(request); + send(exchange, 202, configurationOperations.createRead(request.nodeIds(), request.configuration())); return; } if ((CONFIGURATION + "/preview").equals(path)) { requireMethod(exchange, "POST"); authenticateAdmin(exchange, true); ConfigurationRequests.Preview request = read(exchange, ConfigurationRequests.Preview.class); + requireRequest(request); send(exchange, 202, configurationOperations.createPreview(request.nodeIds(), request.configuration())); return; } @@ -271,6 +291,7 @@ private void route(HttpExchange exchange) throws IOException { requireMethod(exchange, "POST"); authenticateAdmin(exchange, true); ConfigurationRequests.Apply request = read(exchange, ConfigurationRequests.Apply.class); + requireRequest(request); send(exchange, 202, configurationOperations.createApply(request.previewOperationId(), request.approvalToken())); return; } @@ -305,6 +326,7 @@ private void route(HttpExchange exchange) throws IOException { requireMethod(exchange, "POST"); authenticateNode(exchange, nodeId); ConfigurationRequests.Claim claim = read(exchange, ConfigurationRequests.Claim.class); + requireRequest(claim); ConfigurationTask task = configurationOperations.claim(nodeId, claim.sessionId()); if (task == null) { noContent(exchange); @@ -369,6 +391,13 @@ private T read(HttpExchange exchange, Class type) throws IOException { return json.readValue(text, type); } + private static void requireRequest(Object request) { + if (request == null) { + throw new ValidationException("VALIDATION_ERROR", "Request validation failed", + List.of("request body must be a JSON object")); + } + } + private void authenticateNode(HttpExchange exchange, String nodeId) { authenticate(exchange, token -> credentials.verifyNode(nodeId, token)); } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationRequests.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationRequests.java index 2b8774b5..a1b63a9c 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationRequests.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationRequests.java @@ -6,12 +6,16 @@ public final class ConfigurationRequests { private ConfigurationRequests() { } - public record Read(List nodeIds) { + public record Read(List nodeIds, ManagedConfiguration configuration) { public Read { nodeIds = nodeIds == null ? List.of() : List.copyOf(nodeIds); } + public Read(List nodeIds) { this(nodeIds, null); } } - public record Preview(List nodeIds, ProxyRoutingConfiguration configuration) { + public record Preview(List nodeIds, ManagedConfiguration configuration) { public Preview { nodeIds = nodeIds == null ? List.of() : List.copyOf(nodeIds); } + public Preview(List nodeIds, ProxyRoutingConfiguration configuration) { + this(nodeIds, configuration == null ? null : ManagedConfiguration.proxy(configuration)); + } } public record Apply(UUID previewOperationId, String approvalToken) { } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTask.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTask.java index c5065967..9b0d8275 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTask.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTask.java @@ -2,5 +2,5 @@ import java.util.UUID; -public record ConfigurationTask(UUID operationId, String type, ProxyRoutingConfiguration configuration, +public record ConfigurationTask(UUID operationId, String type, ManagedConfiguration configuration, String expectedRevision) { } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTaskResult.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTaskResult.java index b3a34030..517c5811 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTaskResult.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTaskResult.java @@ -4,9 +4,16 @@ import java.util.UUID; public record ConfigurationTaskResult(UUID sessionId, boolean success, String code, String message, String revision, - ProxyRoutingConfiguration configuration, List changes, + ManagedConfiguration configuration, List changes, boolean reloaded, boolean rolledBack) { public ConfigurationTaskResult { changes = changes == null ? List.of() : List.copyOf(changes); } + + public ConfigurationTaskResult(UUID sessionId, boolean success, String code, String message, String revision, + ProxyRoutingConfiguration configuration, List changes, + boolean reloaded, boolean rolledBack) { + this(sessionId, success, code, message, revision, + configuration == null ? null : ManagedConfiguration.proxy(configuration), changes, reloaded, rolledBack); + } } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java new file mode 100644 index 00000000..7bd62ab7 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java @@ -0,0 +1,71 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Versioned union of the configuration domains negotiated with VotingPlugin nodes. */ +public record ManagedConfiguration(String domain, Boolean sendVotesToAllServers, List blockedServers, + String fileName, String content, String preset, Map options) { + public static final String PROXY_ROUTING = "proxy-routing"; + public static final String FILE = "file"; + public static final String QUICK_SETUP = "quick-setup"; + public static final int MAX_CONTENT = 512 * 1024; + + public ManagedConfiguration { + domain = domain == null && sendVotesToAllServers != null ? PROXY_ROUTING : domain; + blockedServers = blockedServers == null ? List.of() : List.copyOf(blockedServers); + options = options == null ? Map.of() : Map.copyOf(new LinkedHashMap<>(options)); + if (!List.of(PROXY_ROUTING, FILE, QUICK_SETUP).contains(domain)) { + throw new IllegalArgumentException("configuration domain is unsupported"); + } + if (PROXY_ROUTING.equals(domain)) { + if (sendVotesToAllServers == null) throw new IllegalArgumentException("proxy routing is incomplete"); + new ProxyRoutingConfiguration(sendVotesToAllServers, blockedServers); + } else if (FILE.equals(domain)) { + validateFileName(fileName); + if (content != null && (content.length() > MAX_CONTENT || content.indexOf('\0') >= 0)) { + throw new IllegalArgumentException("configuration file content is invalid"); + } + } else { + if (preset == null || !preset.matches("[a-z][a-z0-9-]{0,39}")) { + throw new IllegalArgumentException("quick setup preset is invalid"); + } + if (options.size() > 20 || options.entrySet().stream().anyMatch(entry -> entry.getKey() == null + || !entry.getKey().matches("[a-z][A-Za-z0-9]{0,39}") || entry.getValue() == null + || entry.getValue().length() > 500 || entry.getValue().indexOf('\0') >= 0)) { + throw new IllegalArgumentException("quick setup options are invalid"); + } + } + } + + public static ManagedConfiguration proxy(ProxyRoutingConfiguration value) { + return new ManagedConfiguration(PROXY_ROUTING, value.sendVotesToAllServers(), value.blockedServers(), + null, null, null, Map.of()); + } + + public static ManagedConfiguration file(String fileName, String content) { + return new ManagedConfiguration(FILE, null, List.of(), fileName, content, null, Map.of()); + } + + public String capability() { + return switch (domain) { + case PROXY_ROUTING -> "config.proxy-routing.v1"; + case FILE -> "config.files.v1"; + case QUICK_SETUP -> "config.quick-setup.v1"; + default -> throw new IllegalStateException("unsupported configuration domain"); + }; + } + + /** Omits file contents so proposals, including newly entered secrets, are never echoed by operation APIs. */ + public ManagedConfiguration publicView() { + return FILE.equals(domain) ? file(fileName, null) : this; + } + + private static void validateFileName(String value) { + if (value == null || value.length() > 160 + || !value.matches("(?:Config|VoteSites|SpecialRewards|GUI|Shop|BungeeSettings)\\.yml|VoteSites/[A-Za-z0-9._-]{1,100}\\.yml")) { + throw new IllegalArgumentException("configuration file name is not managed"); + } + } +} diff --git a/src/main/resources/web/app.css b/src/main/resources/web/app.css index e0f80f2d..5472ceec 100644 --- a/src/main/resources/web/app.css +++ b/src/main/resources/web/app.css @@ -17,7 +17,7 @@ h1 { margin-bottom: 0; font-size: clamp(2rem, 5vw, 3.5rem); } .status.online { color: #8ef0b1; border-color: #28734b; background: #0c2c20; } .card { margin-top: 24px; padding: 24px; border: 1px solid #263b57; border-radius: 18px; background: rgba(10, 25, 45, .88); box-shadow: 0 18px 60px rgba(0, 0, 0, .22); } label { display: block; margin-bottom: 8px; font-weight: 700; } -input, textarea { flex: 1; min-width: 0; padding: 12px 14px; border: 1px solid #38506f; border-radius: 10px; background: #07101d; color: inherit; } +input, textarea, select { flex: 1; min-width: 0; padding: 12px 14px; border: 1px solid #38506f; border-radius: 10px; background: #07101d; color: inherit; } button { padding: 12px 16px; border: 0; border-radius: 10px; background: #3187ee; color: white; font-weight: 700; cursor: pointer; } button:disabled { opacity: .5; cursor: not-allowed; } #message { min-height: 1.5em; margin: 14px 0 0; color: #aabbd0; } @@ -30,7 +30,13 @@ button:disabled { opacity: .5; cursor: not-allowed; } label span { color: #aabbd0; font-weight: 400; } textarea { width: 100%; resize: vertical; margin-bottom: 14px; } .operation-actions { display: flex; flex-wrap: wrap; gap: 10px; } -#operation-status { min-height: 84px; margin: 18px 0 0; padding: 14px; overflow: auto; border: 1px solid #263b57; border-radius: 10px; background: #07101d; color: #c6d5e8; white-space: pre-wrap; } +#operation-status, #file-operation-status, #quick-operation-status { min-height: 84px; margin: 18px 0 0; padding: 14px; overflow: auto; border: 1px solid #263b57; border-radius: 10px; background: #07101d; color: #c6d5e8; white-space: pre-wrap; } +.yaml-editor { margin-top: 8px; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .9rem; line-height: 1.45; } +.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin: 16px 0; } +.form-grid label { color: #aabbd0; } +.form-grid input { display: block; width: 100%; margin-top: 7px; } +select { width: 100%; margin-bottom: 16px; } +code { color: #8ef0b1; } .node h3 { margin-bottom: 6px; } .node p { color: #aabbd0; } .pagination { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 20px; } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 4a372ced..5416dc74 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -16,12 +16,29 @@ const readConfiguration = document.querySelector('#read-configuration'); const previewConfiguration = document.querySelector('#preview-configuration'); const applyConfiguration = document.querySelector('#apply-configuration'); const operationStatus = document.querySelector('#operation-status'); +const configurationFile = document.querySelector('#configuration-file'); +const configurationContent = document.querySelector('#configuration-content'); +const readFileConfiguration = document.querySelector('#read-file-configuration'); +const previewFileConfiguration = document.querySelector('#preview-file-configuration'); +const applyFileConfiguration = document.querySelector('#apply-file-configuration'); +const fileOperationStatus = document.querySelector('#file-operation-status'); +const quickPreset = document.querySelector('#quick-preset'); +const quickName = document.querySelector('#quick-name'); +const quickService = document.querySelector('#quick-service'); +const quickUrl = document.querySelector('#quick-url'); +const quickDelay = document.querySelector('#quick-delay'); +const previewQuickSetup = document.querySelector('#preview-quick-setup'); +const applyQuickSetup = document.querySelector('#apply-quick-setup'); +const quickOperationStatus = document.querySelector('#quick-operation-status'); const PAGE_SIZE = 100; let authenticated = false; let csrfToken = ''; let pageOffset = 0; let selectedNodes = new Set(); let approvedPreview = null; +let approvedFilePreview = null; +let approvedQuickPreview = null; +let nodeCapabilities = new Map(); let inputGeneration = 0; function text(element, value) { @@ -60,17 +77,21 @@ function nodeCard(node) { selector.className = 'node-select'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; - checkbox.disabled = !node.online || !node.acceptedCapabilities.includes('config.proxy-routing.v1'); + const controllable = ['config.proxy-routing.v1', 'config.files.v1', 'config.quick-setup.v1'] + .some(capability => node.acceptedCapabilities.includes(capability)); + checkbox.disabled = !node.online || !controllable; checkbox.checked = selectedNodes.has(node.nodeId) && !checkbox.disabled; checkbox.addEventListener('change', () => { if (checkbox.checked) selectedNodes.add(node.nodeId); else selectedNodes.delete(node.nodeId); approvedPreview = null; + approvedFilePreview = null; + approvedQuickPreview = null; inputGeneration++; updateConfigurationButtons(); }); selector.append(checkbox, title); const meta = text(document.createElement('p'), - `${node.platform} · VotingPlugin ${node.pluginVersion} · ${node.online ? 'online' : 'offline'}`); + `${node.platform} · VotingPlugin ${node.pluginVersion} · ${node.online ? 'online' : 'offline'} · ${node.acceptedCapabilities.filter(value => value.startsWith('config.')).join(', ') || 'discovery only'}`); const list = document.createElement('ul'); const backends = Array.isArray(node.backends) ? node.backends : []; if (backends.length === 0) { @@ -83,10 +104,29 @@ function nodeCard(node) { } function updateConfigurationButtons(busy = false) { - const ready = authenticated && selectedNodes.size > 0 && !busy; - readConfiguration.disabled = !ready; - previewConfiguration.disabled = !ready; - applyConfiguration.disabled = !ready || !approvedPreview; + const routingReady = authenticated && targets('config.proxy-routing.v1').length > 0 && !busy; + const fileReady = authenticated && targets('config.files.v1').length > 0 && !busy; + const quickReady = authenticated && targets('config.quick-setup.v1').length > 0 && !busy; + readConfiguration.disabled = !routingReady; + previewConfiguration.disabled = !routingReady; + applyConfiguration.disabled = !routingReady || !approvedPreview; + readFileConfiguration.disabled = !fileReady; + previewFileConfiguration.disabled = !fileReady || !configurationContent.value; + applyFileConfiguration.disabled = !fileReady || !approvedFilePreview; + previewQuickSetup.disabled = !quickReady; + applyQuickSetup.disabled = !quickReady || !approvedQuickPreview; +} + +function targets(capability) { + return [...selectedNodes].filter(node => nodeCapabilities.get(node)?.includes(capability)); +} + +function clearApprovals() { + approvedPreview = null; + approvedFilePreview = null; + approvedQuickPreview = null; + inputGeneration++; + updateConfigurationButtons(); } async function authorized(path, options = {}) { @@ -120,22 +160,22 @@ function operationSummary(operation) { return lines.join('\n'); } -async function waitForOperation(operation) { - text(operationStatus, operationSummary(operation)); +async function waitForOperation(operation, statusElement = operationStatus) { + text(statusElement, operationSummary(operation)); while (operation.state === 'RUNNING') { await new Promise(resolve => window.setTimeout(resolve, 1500)); operation = await authorized(`/api/v1/operations/${operation.operationId}`); - text(operationStatus, operationSummary(operation)); + text(statusElement, operationSummary(operation)); } return operation; } -async function startConfigurationOperation(path, body) { +async function startConfigurationOperation(path, body, statusElement = operationStatus) { updateConfigurationButtons(true); try { return await waitForOperation(await authorized(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) - })); + }), statusElement); } finally { updateConfigurationButtons(false); } @@ -156,11 +196,14 @@ async function loadNodes() { } else { body.items.forEach(node => nodes.append(nodeCard(node))); } - const visibleIds = new Set(body.items.filter(node => node.online && node.acceptedCapabilities.includes('config.proxy-routing.v1')) + nodeCapabilities = new Map(body.items.map(node => [node.nodeId, node.acceptedCapabilities])); + const visibleIds = new Set(body.items.filter(node => node.online && node.acceptedCapabilities.some(value => value.startsWith('config.'))) .map(node => node.nodeId)); const filteredSelection = new Set([...selectedNodes].filter(node => visibleIds.has(node))); if (filteredSelection.size !== selectedNodes.size) { approvedPreview = null; + approvedFilePreview = null; + approvedQuickPreview = null; inputGeneration++; text(operationStatus, 'The selected proxies changed during refresh. Preview again before apply.'); } @@ -196,6 +239,8 @@ form.addEventListener('submit', async event => { authenticated = true; csrfToken = body.csrfToken; approvedPreview = null; + approvedFilePreview = null; + approvedQuickPreview = null; selectedNodes.clear(); inputGeneration++; logout.hidden = false; @@ -211,9 +256,12 @@ logout.addEventListener('click', async () => { authenticated = false; csrfToken = ''; approvedPreview = null; + approvedFilePreview = null; + approvedQuickPreview = null; inputGeneration++; logout.hidden = true; selectedNodes.clear(); + nodeCapabilities.clear(); nodes.replaceChildren(); nodes.classList.add('empty'); text(nodes, 'Authenticate to view the network.'); @@ -236,7 +284,7 @@ async function restoreSession() { readConfiguration.addEventListener('click', async () => { approvedPreview = null; try { - const operation = await startConfigurationOperation('/api/v1/configuration/read', {nodeIds: [...selectedNodes]}); + const operation = await startConfigurationOperation('/api/v1/configuration/read', {nodeIds: targets('config.proxy-routing.v1')}); const successful = Object.values(operation.results).filter(result => result.success); if (successful.length) { const first = successful[0].configuration; @@ -251,7 +299,7 @@ previewConfiguration.addEventListener('click', async () => { const previewGeneration = inputGeneration; try { const operation = await startConfigurationOperation('/api/v1/configuration/preview', { - nodeIds: [...selectedNodes], configuration: proposal() + nodeIds: targets('config.proxy-routing.v1'), configuration: proposal() }); if (operation.state === 'SUCCEEDED' && operation.approvalToken && previewGeneration === inputGeneration) { @@ -280,6 +328,97 @@ applyConfiguration.addEventListener('click', async () => { inputGeneration++; updateConfigurationButtons(); })); + +readFileConfiguration.addEventListener('click', async () => { + approvedFilePreview = null; + try { + const operation = await startConfigurationOperation('/api/v1/configuration/read', { + nodeIds: targets('config.files.v1'), + configuration: {domain: 'file', fileName: configurationFile.value} + }, fileOperationStatus); + const successful = Object.values(operation.results).filter(result => result.success); + if (successful.length) { + configurationContent.value = successful[0].configuration.content; + text(fileOperationStatus, operationSummary(operation)); + inputGeneration++; + updateConfigurationButtons(); + } + } catch (error) { text(fileOperationStatus, error.message); } +}); + +previewFileConfiguration.addEventListener('click', async () => { + approvedFilePreview = null; + const previewGeneration = inputGeneration; + try { + const operation = await startConfigurationOperation('/api/v1/configuration/preview', { + nodeIds: targets('config.files.v1'), + configuration: {domain: 'file', fileName: configurationFile.value, content: configurationContent.value} + }, fileOperationStatus); + text(fileOperationStatus, operationSummary(operation)); + if (operation.state === 'SUCCEEDED' && operation.approvalToken && previewGeneration === inputGeneration) { + approvedFilePreview = {operationId: operation.operationId, approvalToken: operation.approvalToken}; + updateConfigurationButtons(); + } else if (previewGeneration !== inputGeneration) { + text(fileOperationStatus, 'The targets or file changed while previewing. Preview again before apply.'); + } + } catch (error) { text(fileOperationStatus, error.message); } +}); + +applyFileConfiguration.addEventListener('click', async () => { + if (!approvedFilePreview || !window.confirm(`Apply this exact ${configurationFile.value} preview to every selected Bukkit node?`)) return; + const approval = approvedFilePreview; + approvedFilePreview = null; + inputGeneration++; + try { + const operation = await startConfigurationOperation('/api/v1/configuration/apply', { + previewOperationId: approval.operationId, approvalToken: approval.approvalToken + }, fileOperationStatus); + text(fileOperationStatus, operationSummary(operation)); + } catch (error) { text(fileOperationStatus, error.message); } +}); + +function quickOptions() { + if (quickPreset.value === 'standalone') return {}; + if (quickPreset.value === 'proxy-backend') return {server: quickName.value.trim(), method: 'PLUGINMESSAGING'}; + return { + name: quickName.value.trim(), displayName: quickName.value.trim(), serviceSite: quickService.value.trim(), + voteUrl: quickUrl.value.trim(), voteDelay: quickDelay.value.trim(), priority: '5', material: 'DIAMOND' + }; +} + +previewQuickSetup.addEventListener('click', async () => { + approvedQuickPreview = null; + const previewGeneration = inputGeneration; + try { + const operation = await startConfigurationOperation('/api/v1/configuration/preview', { + nodeIds: targets('config.quick-setup.v1'), + configuration: {domain: 'quick-setup', preset: quickPreset.value, options: quickOptions()} + }, quickOperationStatus); + text(quickOperationStatus, operationSummary(operation)); + if (operation.state === 'SUCCEEDED' && operation.approvalToken && previewGeneration === inputGeneration) { + approvedQuickPreview = {operationId: operation.operationId, approvalToken: operation.approvalToken}; + updateConfigurationButtons(); + } else if (previewGeneration !== inputGeneration) { + text(quickOperationStatus, 'The targets or setup changed while previewing. Preview again before apply.'); + } + } catch (error) { text(quickOperationStatus, error.message); } +}); + +applyQuickSetup.addEventListener('click', async () => { + if (!approvedQuickPreview || !window.confirm('Apply this exact quick setup to every selected Bukkit node?')) return; + const approval = approvedQuickPreview; + approvedQuickPreview = null; + inputGeneration++; + try { + const operation = await startConfigurationOperation('/api/v1/configuration/apply', { + previewOperationId: approval.operationId, approvalToken: approval.approvalToken + }, quickOperationStatus); + text(quickOperationStatus, operationSummary(operation)); + } catch (error) { text(quickOperationStatus, error.message); } +}); + +[configurationFile, configurationContent, quickPreset, quickName, quickService, quickUrl, quickDelay] + .forEach(field => field.addEventListener('input', clearApprovals)); refresh.addEventListener('click', loadNodes); previousPage.addEventListener('click', () => { pageOffset = Math.max(0, pageOffset - PAGE_SIZE); diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index cc281ce5..ece187fe 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -49,7 +49,7 @@

Proxies and backends

Proxy routing configuration

-

Only the typed, non-secret routing domain is writable. Select capable proxies above, read or preview them, then explicitly approve the exact preview.

+

Select capable proxies above, read or preview them, then explicitly approve the exact preview.

@@ -64,6 +64,62 @@

Proxy routing configuration

Select at least one online, capable proxy.
+ +
+
+
+

Full VotingPlugin configuration

+

Edit every managed Bukkit YAML file. Secret values are returned as __VOTINGPLUGIN_CONTROL_REDACTED__; leave that marker unchanged to preserve the stored value. Reads normalize YAML, so comments may not be retained.

+
+
+
+ + + + +
+ + + +
+
+
Select at least one online Bukkit node with file control.
+
+ +
+
+
+

Quick setups

+

Generate a safe preview for common backend and vote-site setups, then approve the exact result.

+
+
+
+ + +
+ + + + +
+
+ + +
+
+
Select at least one online Bukkit node with quick setup control.
+
diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 511cdab1..68c86b5f 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -8,6 +8,7 @@ import com.bencodez.votingplugin.control.protocol.ConfigurationTaskResult; import com.bencodez.votingplugin.control.protocol.NodeRegistration; import com.bencodez.votingplugin.control.protocol.ProxyRoutingConfiguration; +import com.bencodez.votingplugin.control.protocol.ManagedConfiguration; import java.nio.file.Path; import java.nio.file.Files; import java.time.Clock; @@ -57,8 +58,10 @@ class ConfigurationOperationsTest { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); ConfigurationAuditLog first = new ConfigurationAuditLog(directory, clock); first.append("TEST", UUID.randomUUID(), "proxy-a", "OK"); + first.close(); ConfigurationAuditLog reopened = new ConfigurationAuditLog(directory, clock); reopened.append("TEST", UUID.randomUUID(), null, "OK"); + reopened.close(); Path file = directory.resolve("configuration-audit.jsonl"); Files.writeString(file, Files.readString(file).replaceFirst("TEST", "EDIT")); assertThrows(java.io.IOException.class, () -> new ConfigurationAuditLog(directory, clock)); @@ -108,6 +111,7 @@ class ConfigurationOperationsTest { ConfigurationAuditLog first = new ConfigurationAuditLog(directory, clock, 1); first.append("TEST", UUID.randomUUID(), "proxy-a", "OK"); first.append("TEST", UUID.randomUUID(), "proxy-b", "OK"); + first.close(); Files.writeString(retained, Files.readString(retained).replaceFirst("TEST", "EDIT")); assertThrows(java.io.IOException.class, () -> new ConfigurationAuditLog(directory, clock)); @@ -119,12 +123,20 @@ class ConfigurationOperationsTest { ConfigurationAuditLog log = new ConfigurationAuditLog(directory, clock); log.append("FIRST", UUID.randomUUID(), "proxy-a", "OK"); log.append("SECOND", UUID.randomUUID(), "proxy-a", "OK"); + log.close(); List records = Files.readAllLines(active); Files.writeString(active, records.get(0) + System.lineSeparator()); assertThrows(java.io.IOException.class, () -> new ConfigurationAuditLog(directory, clock)); } + @Test void auditLogRejectsASecondProcessWriter() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + try (ConfigurationAuditLog first = new ConfigurationAuditLog(directory, clock)) { + assertThrows(java.io.IOException.class, () -> new ConfigurationAuditLog(directory, clock)); + } + } + @Test void operationLimitDoesNotConsumePreviewApproval() throws Exception { MutableClock clock = new MutableClock(Instant.parse("2026-08-25T00:00:00Z")); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofHours(1)); @@ -147,6 +159,30 @@ class ConfigurationOperationsTest { assertEquals("APPLY", operations.createApply(previewId, approval).type()); } + @Test void fileAndQuickSetupOperationsRequireTheirNegotiatedCapabilities() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID session = UUID.randomUUID(); + registry.register(new NodeRegistration("backend-a", session, "Backend A", "BUKKIT", "test", 1, + Set.of(ConfigurationOperations.FILE_CAPABILITY, ConfigurationOperations.QUICK_SETUP_CAPABILITY), + Set.of(ConfigurationOperations.FILE_CAPABILITY))); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + + ManagedConfiguration file = ManagedConfiguration.file("Config.yml", "Feature: true\n"); + ConfigurationOperations.OperationView preview = operations.createPreview(List.of("backend-a"), file); + assertEquals(null, preview.configuration().content()); + assertEquals("file", operations.claim("backend-a", session).configuration().domain()); + ManagedConfiguration current = ManagedConfiguration.file("Config.yml", "Feature: false\n"); + preview = operations.complete(preview.operationId(), "backend-a", new ConfigurationTaskResult(session, true, + "OK", "valid", "d".repeat(64), current, List.of("changed Feature"), false, false)); + assertNotNull(preview.approvalToken()); + + assertEquals("NODE_UNAVAILABLE", assertThrows(ValidationException.class, + () -> operations.createPreview(List.of("backend-a"), + ManagedConfiguration.proxy(new ProxyRoutingConfiguration(false, List.of())))).code()); + } + private static final class MutableClock extends Clock { private Instant instant; private MutableClock(Instant instant) { this.instant = instant; } diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index a6842a16..5a2bd9b0 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -196,6 +196,12 @@ class ControlHttpServerTest { assertError(send("POST", "/api/v1/nodes/register", "{", nodeToken), 400, "MALFORMED_JSON"); assertError(send("POST", "/api/v1/nodes/register", "", nodeToken), 400, "MALFORMED_JSON"); assertError(send("POST", "/api/v1/nodes/register", "null", nodeToken), 400, "VALIDATION_ERROR"); + assertError(send("POST", "/api/v1/auth/login", "null", null), 400, "VALIDATION_ERROR"); + assertError(send("POST", "/api/v1/configuration/read", "null", adminToken), 400, "VALIDATION_ERROR"); + assertError(send("POST", "/api/v1/configuration/preview", "null", adminToken), 400, "VALIDATION_ERROR"); + assertError(send("POST", "/api/v1/configuration/apply", "null", adminToken), 400, "VALIDATION_ERROR"); + assertError(send("POST", "/api/v1/nodes/proxy-a/operations", "null", nodeToken), 400, + "VALIDATION_ERROR"); String duplicate = registration().replaceFirst("\\{", "{\"nodeId\":\"proxy-a\","); assertError(send("POST", "/api/v1/nodes/register", duplicate, nodeToken), 400, "MALFORMED_JSON"); String nested = "[".repeat(25) + "0" + "]".repeat(25); From e7252ec44ccddae5ef78b40f5a246fbf89561708 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 25 Aug 2026 08:51:38 -0600 Subject: [PATCH 14/42] Bound file operations and approval targets --- .../domain/ConfigurationOperations.java | 41 +++++++++++++++++-- src/main/resources/web/app.js | 24 +++++++++-- .../domain/ConfigurationOperationsTest.java | 29 +++++++++++++ .../control/http/ControlHttpServerTest.java | 1 + 4 files changed, 87 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 8aaeaf25..00325752 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -27,6 +27,7 @@ public final class ConfigurationOperations implements AutoCloseable { public static final String FILE_CAPABILITY = "config.files.v1"; public static final String QUICK_SETUP_CAPABILITY = "config.quick-setup.v1"; private static final int MAX_OPERATIONS = 1000; + private static final int MAX_FILE_OPERATIONS = 16; private static final Duration LEASE = Duration.ofMinutes(2); private static final Duration ACTIVE_RETENTION = Duration.ofMinutes(15); private static final Duration RETENTION = Duration.ofHours(24); @@ -79,7 +80,7 @@ public synchronized OperationView createApply(UUID previewId, String approvalTok Map revisions = new LinkedHashMap<>(); preview.results.forEach((node, result) -> revisions.put(node, result.revision())); StoredOperation apply = store("APPLY", new ArrayList<>(preview.states.keySet()), preview.configuration, - null, revisions); + null, revisions, preview.id); preview.approvalUsed = true; try { audit.append("APPLY_APPROVED", apply.id, null, "QUEUED"); @@ -141,14 +142,14 @@ private OperationView completeCurrentSession(UUID operationId, String nodeId, Co throw new ValidationException("TASK_NOT_CLAIMED", "Operation task must be claimed before completion", List.of()); } audit.append("TASK_COMPLETED", operation.id, nodeId, result.success() ? "SUCCESS" : result.code()); - operation.results.put(nodeId, result); + operation.results.put(nodeId, boundedResult(operation, result)); operation.states.put(nodeId, "COMPLETE"); operation.leasedAt.remove(nodeId); return view(operation); } private OperationView create(String type, List targets, ManagedConfiguration config, String token) { - StoredOperation operation = store(type, targets, config, token, Map.of()); + StoredOperation operation = store(type, targets, config, token, Map.of(), null); try { audit.append("OPERATION_CREATED", operation.id, null, type); } catch (RuntimeException e) { @@ -159,8 +160,9 @@ private OperationView create(String type, List targets, ManagedConfigura } private StoredOperation store(String type, List targets, ManagedConfiguration config, - String token, Map revisions) { + String token, Map revisions, UUID protectedOperation) { prune(); + ensureFileCapacity(config, protectedOperation); if (operations.size() >= MAX_OPERATIONS) throw new ValidationException("OPERATION_LIMIT", "Too many retained operations", List.of()); UUID id = UUID.randomUUID(); @@ -172,6 +174,34 @@ private StoredOperation store(String type, List targets, ManagedConfigur return result; } + private void ensureFileCapacity(ManagedConfiguration config, UUID protectedOperation) { + if (config == null || !ManagedConfiguration.FILE.equals(config.domain())) return; + long retained = operations.values().stream().filter(StoredOperation::fileOperation).count(); + Iterator> iterator = operations.entrySet().iterator(); + while (retained >= MAX_FILE_OPERATIONS && iterator.hasNext()) { + StoredOperation candidate = iterator.next().getValue(); + if (candidate.fileOperation() && candidate.complete() && !candidate.id.equals(protectedOperation)) { + audit.append("OPERATION_EVICTED", candidate.id, null, "FILE_RETENTION_LIMIT"); + iterator.remove(); + retained--; + } + } + if (retained >= MAX_FILE_OPERATIONS) { + throw new ValidationException("OPERATION_LIMIT", "Too many active file operations", List.of()); + } + } + + private static ConfigurationTaskResult boundedResult(StoredOperation operation, ConfigurationTaskResult result) { + ManagedConfiguration configuration = result.configuration(); + if (configuration == null || !ManagedConfiguration.FILE.equals(configuration.domain())) return result; + boolean keepContent = "READ".equals(operation.type) && operation.results.values().stream() + .map(ConfigurationTaskResult::configuration).filter(Objects::nonNull) + .noneMatch(value -> ManagedConfiguration.FILE.equals(value.domain()) && value.content() != null); + if (keepContent) return result; + return new ConfigurationTaskResult(result.sessionId(), result.success(), result.code(), result.message(), + result.revision(), configuration.publicView(), result.changes(), result.reloaded(), result.rolledBack()); + } + private List validateTargets(List nodeIds, String capability) { if (nodeIds == null || nodeIds.isEmpty() || nodeIds.size() > 100) throw invalid("nodeIds must contain 1 to 100 entries"); Set unique = new HashSet<>(); @@ -261,5 +291,8 @@ private StoredOperation(UUID id, String type, ManagedConfiguration configuration this.expectedRevisions = expectedRevisions; } private boolean complete() { return states.values().stream().allMatch("COMPLETE"::equals); } + private boolean fileOperation() { + return configuration != null && ManagedConfiguration.FILE.equals(configuration.domain()); + } } } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 5416dc74..9fa56a32 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -196,7 +196,20 @@ async function loadNodes() { } else { body.items.forEach(node => nodes.append(nodeCard(node))); } - nodeCapabilities = new Map(body.items.map(node => [node.nodeId, node.acceptedCapabilities])); + nodeCapabilities = new Map(body.items.map(node => [node.nodeId, node.online ? node.acceptedCapabilities : []])); + const invalidRoutingApproval = approvedPreview && !approvedPreview.nodeIds.every(node => + nodeCapabilities.get(node)?.includes('config.proxy-routing.v1')); + const invalidFileApproval = approvedFilePreview && !approvedFilePreview.nodeIds.every(node => + nodeCapabilities.get(node)?.includes('config.files.v1')); + const invalidQuickApproval = approvedQuickPreview && !approvedQuickPreview.nodeIds.every(node => + nodeCapabilities.get(node)?.includes('config.quick-setup.v1')); + if (invalidRoutingApproval || invalidFileApproval || invalidQuickApproval) { + if (invalidRoutingApproval) approvedPreview = null; + if (invalidFileApproval) approvedFilePreview = null; + if (invalidQuickApproval) approvedQuickPreview = null; + inputGeneration++; + text(operationStatus, 'A preview target went offline or lost the required capability. Preview again before apply.'); + } const visibleIds = new Set(body.items.filter(node => node.online && node.acceptedCapabilities.some(value => value.startsWith('config.'))) .map(node => node.nodeId)); const filteredSelection = new Set([...selectedNodes].filter(node => visibleIds.has(node))); @@ -303,7 +316,8 @@ previewConfiguration.addEventListener('click', async () => { }); if (operation.state === 'SUCCEEDED' && operation.approvalToken && previewGeneration === inputGeneration) { - approvedPreview = {operationId: operation.operationId, approvalToken: operation.approvalToken}; + approvedPreview = {operationId: operation.operationId, approvalToken: operation.approvalToken, + nodeIds: targets('config.proxy-routing.v1')}; updateConfigurationButtons(); } else if (previewGeneration !== inputGeneration) { text(operationStatus, 'The targets or proposal changed while previewing. Preview again before apply.'); @@ -356,7 +370,8 @@ previewFileConfiguration.addEventListener('click', async () => { }, fileOperationStatus); text(fileOperationStatus, operationSummary(operation)); if (operation.state === 'SUCCEEDED' && operation.approvalToken && previewGeneration === inputGeneration) { - approvedFilePreview = {operationId: operation.operationId, approvalToken: operation.approvalToken}; + approvedFilePreview = {operationId: operation.operationId, approvalToken: operation.approvalToken, + nodeIds: targets('config.files.v1')}; updateConfigurationButtons(); } else if (previewGeneration !== inputGeneration) { text(fileOperationStatus, 'The targets or file changed while previewing. Preview again before apply.'); @@ -396,7 +411,8 @@ previewQuickSetup.addEventListener('click', async () => { }, quickOperationStatus); text(quickOperationStatus, operationSummary(operation)); if (operation.state === 'SUCCEEDED' && operation.approvalToken && previewGeneration === inputGeneration) { - approvedQuickPreview = {operationId: operation.operationId, approvalToken: operation.approvalToken}; + approvedQuickPreview = {operationId: operation.operationId, approvalToken: operation.approvalToken, + nodeIds: targets('config.quick-setup.v1')}; updateConfigurationButtons(); } else if (previewGeneration !== inputGeneration) { text(quickOperationStatus, 'The targets or setup changed while previewing. Preview again before apply.'); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 68c86b5f..e81ebe2a 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -183,6 +183,35 @@ class ConfigurationOperationsTest { ManagedConfiguration.proxy(new ProxyRoutingConfiguration(false, List.of())))).code()); } + @Test void fileOperationRetentionAndMultiNodeContentsAreBounded() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID first = UUID.randomUUID(); + UUID second = UUID.randomUUID(); + Set capabilities = Set.of(ConfigurationOperations.FILE_CAPABILITY); + registry.register(new NodeRegistration("backend-a", first, "Backend A", "BUKKIT", "test", 1, + capabilities, capabilities)); + registry.register(new NodeRegistration("backend-b", second, "Backend B", "BUKKIT", "test", 1, + capabilities, capabilities)); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + ManagedConfiguration selector = ManagedConfiguration.file("Config.yml", null); + ConfigurationOperations.OperationView read = operations.createRead(List.of("backend-a", "backend-b"), selector); + operations.claim("backend-a", first); + operations.claim("backend-b", second); + ManagedConfiguration content = ManagedConfiguration.file("Config.yml", "Feature: true\n"); + operations.complete(read.operationId(), "backend-a", new ConfigurationTaskResult(first, true, "OK", "read", + "e".repeat(64), content, List.of(), false, false)); + read = operations.complete(read.operationId(), "backend-b", new ConfigurationTaskResult(second, true, "OK", + "read", "f".repeat(64), content, List.of(), false, false)); + assertNotNull(read.results().get("backend-a").configuration().content()); + assertEquals(null, read.results().get("backend-b").configuration().content()); + + for (int i = 0; i < 16; i++) operations.createRead(List.of("backend-a"), selector); + assertEquals("OPERATION_LIMIT", assertThrows(ValidationException.class, + () -> operations.createRead(List.of("backend-a"), selector)).code()); + } + private static final class MutableClock extends Clock { private Instant instant; private MutableClock(Instant instant) { this.instant = instant; } diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 5a2bd9b0..3bba6441 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -65,6 +65,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("nextPage.addEventListener")); assertTrue(script.body().contains("filteredSelection.size !== selectedNodes.size")); assertTrue(script.body().contains("previewGeneration === inputGeneration")); + assertTrue(script.body().contains("approvedPreview.nodeIds.every")); assertEquals(200, get("/app.css", null).statusCode()); assertError(send("POST", "/", null, null), 405, "METHOD_NOT_ALLOWED"); assertEquals(200, get("/api/v1/health", null).statusCode()); From 814f5ec06673aaf1ee3dc16e2ba502cc33cf68a8 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 25 Aug 2026 10:23:53 -0600 Subject: [PATCH 15/42] Add plugin-aware administration shortcuts --- README.md | 7 +- .../control/domain/ConfigurationAuditLog.java | 129 +++++++++++++++--- .../control/domain/InMemoryNodeRegistry.java | 27 +++- .../control/protocol/NodeRegistration.java | 10 +- .../control/protocol/NodeStatus.java | 11 +- src/main/resources/web/app.css | 4 + src/main/resources/web/app.js | 98 +++++++++++-- src/main/resources/web/index.html | 30 +++- .../domain/ConfigurationOperationsTest.java | 39 ++++++ .../control/http/ControlHttpServerTest.java | 7 +- 10 files changed, 322 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 72cdd391..3cbaed43 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,9 @@ Control records its own observation time; it does not trust remote wall-clock ti Configuration is split into independently negotiated capabilities. `config.proxy-routing.v1` exposes typed proxy routing. `config.files.v1` manages `Config.yml`, `VoteSites.yml`, `SpecialRewards.yml`, `GUI.yml`, `Shop.yml`, and `BungeeSettings.yml` on enrolled Bukkit nodes through a bounded YAML editor. `config.quick-setup.v1` supplies standalone, -proxy-backend, and vote-site presets. Password, secret, token, API-key, authorization, and webhook-secret values are masked +proxy-backend, vote-site, easy-reward, common-settings, and vote-party presets. Bukkit registration also reports a bounded +set of installed plugin names so the WebUI can offer editable Minecraft, Essentials, CMI, and LuckPerms command suggestions; +these are suggestions, not executed commands, and follow the normal preview/approval path. Password, secret, token, API-key, authorization, and webhook-secret values are masked on every read; leaving the redaction marker in a proposal preserves the current value. A newly entered secret is accepted only in the authenticated proposal and is not returned in results or written to the audit log. @@ -148,7 +150,8 @@ when applying through the full editor. `configuration-audit.jsonl` records bounded, append-only, hash-chained operation metadata and rotates once at 5 MiB. A cross-process lifetime lock prevents two Control processes from forking the same audit chain. Both the active and retained segment are verified against a durable atomic tail/count checkpoint before startup accepts new -operations, so record-boundary truncation also fails closed. It does not contain configuration +operations, so record-boundary truncation also fails closed. A durable pending-append record makes audit and checkpoint +publication recoverable when the process stops between their writes. It does not contain configuration values, credentials, or approval tokens. Operation queues are bounded; abandoned operations expire after 15 minutes and completed operations after 24 hours. Active operations are intentionally lost on Control restart and must be previewed again. diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java index e5d964cc..c8c163de 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java @@ -24,6 +24,7 @@ public final class ConfigurationAuditLog implements AutoCloseable { private final Path file; private final Path previousFile; private final Path checkpointFile; + private final Path pendingFile; private final FileChannel lockChannel; private final FileLock processLock; private final Clock clock; @@ -44,6 +45,7 @@ public ConfigurationAuditLog(Path dataDirectory, Clock clock) throws IOException this.file = dataDirectory.resolve("configuration-audit.jsonl"); this.previousFile = dataDirectory.resolve("configuration-audit.jsonl.1"); this.checkpointFile = dataDirectory.resolve("configuration-audit.checkpoint"); + this.pendingFile = dataDirectory.resolve("configuration-audit.pending"); this.clock = clock; this.maxBytes = maxBytes; FileChannel acquiredChannel = FileChannel.open(dataDirectory.resolve("configuration-audit.lock"), @@ -54,6 +56,7 @@ public ConfigurationAuditLog(Path dataDirectory, Clock clock) throws IOException if (acquiredLock == null) throw new IOException("Configuration audit is already in use"); this.lockChannel = acquiredChannel; this.processLock = acquiredLock; + recoverPending(); SegmentState retained = validateOptional(previousFile, "Retained configuration audit log"); SegmentState active = validateOptional(file, "Configuration audit log"); retainedHash = retained.tailHash(); @@ -76,37 +79,112 @@ public ConfigurationAuditLog(Path dataDirectory, Clock clock) throws IOException public synchronized void append(String action, UUID operationId, String nodeId, String outcome) { try { - rotateIfNeeded(); + boolean rotate = Files.exists(file) && Files.size(file) >= maxBytes; + String recordPrevious = rotate ? "GENESIS" : previousHash; Map core = new LinkedHashMap<>(); core.put("time", clock.instant().toString()); core.put("action", action); core.put("operationId", operationId == null ? null : operationId.toString()); core.put("nodeId", nodeId); core.put("outcome", outcome); - core.put("previousHash", previousHash); + core.put("previousHash", recordPrevious); byte[] canonical = json.writeValueAsBytes(core); String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical)); core.put("hash", hash); - Files.writeString(file, json.writeValueAsString(core) + System.lineSeparator(), StandardCharsets.UTF_8, - StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE); - previousHash = hash; - activeRecords++; - writeCheckpoint(); + String line = json.writeValueAsString(core) + System.lineSeparator(); + PendingAppend pending = new PendingAppend(line, rotate, previousHash, activeRecords, retainedHash, + retainedRecords, hash, rotate ? 1 : activeRecords + 1, + rotate ? previousHash : retainedHash, rotate ? activeRecords : retainedRecords); + writePending(pending); + completePending(pending); } catch (Exception e) { throw new AuditException(e); } } - private void rotateIfNeeded() throws IOException { - if (Files.exists(file) && Files.size(file) >= maxBytes) { - Files.move(file, previousFile, java.nio.file.StandardCopyOption.REPLACE_EXISTING); - retainedHash = previousHash; - retainedRecords = activeRecords; - previousHash = "GENESIS"; - activeRecords = 0; + private void recoverPending() throws IOException { + if (!Files.exists(pendingFile, LinkOption.NOFOLLOW_LINKS)) return; + if (!Files.isRegularFile(pendingFile, LinkOption.NOFOLLOW_LINKS) || Files.size(pendingFile) > 16384) { + throw new IOException("Configuration audit pending transaction is invalid"); + } + try { + JsonNode stored = json.readTree(Files.readAllBytes(pendingFile)); + if (stored == null || !stored.isObject() || stored.size() != 10 + || !stored.path("rotate").isBoolean() + || !integral(stored, "preActiveRecords") || !integral(stored, "preRetainedRecords") + || !integral(stored, "postActiveRecords") || !integral(stored, "postRetainedRecords")) { + throw new IOException("Configuration audit pending transaction is invalid"); + } + completePending(new PendingAppend(stored.path("line").asText(), stored.path("rotate").asBoolean(), + stored.path("preActiveHash").asText(), stored.path("preActiveRecords").asLong(), + stored.path("preRetainedHash").asText(), stored.path("preRetainedRecords").asLong(), + stored.path("postActiveHash").asText(), stored.path("postActiveRecords").asLong(), + stored.path("postRetainedHash").asText(), stored.path("postRetainedRecords").asLong())); + } catch (IOException e) { + throw e; + } catch (RuntimeException e) { + throw new IOException("Configuration audit pending transaction is invalid", e); } } + private void completePending(PendingAppend pending) throws IOException { + SegmentState retained = validateOptional(previousFile, "Retained configuration audit log"); + SegmentState active = validateOptional(file, "Configuration audit log"); + if (matches(active, pending.postActiveHash(), pending.postActiveRecords()) + && matches(retained, pending.postRetainedHash(), pending.postRetainedRecords())) { + // The record is durable; only checkpoint publication or cleanup was interrupted. + } else if (!pending.rotate() && matches(active, pending.preActiveHash(), pending.preActiveRecords()) + && matches(retained, pending.preRetainedHash(), pending.preRetainedRecords())) { + appendDurably(pending.line()); + } else if (pending.rotate() && matches(active, pending.preActiveHash(), pending.preActiveRecords()) + && matches(retained, pending.preRetainedHash(), pending.preRetainedRecords())) { + moveReplacing(file, previousFile); + appendDurably(pending.line()); + } else if (pending.rotate() && !active.present() + && matches(retained, pending.postRetainedHash(), pending.postRetainedRecords())) { + appendDurably(pending.line()); + } else { + throw new IOException("Configuration audit pending transaction does not match the audit chain"); + } + retainedHash = pending.postRetainedHash(); + retainedRecords = pending.postRetainedRecords(); + previousHash = pending.postActiveHash(); + activeRecords = pending.postActiveRecords(); + writeCheckpoint(); + Files.delete(pendingFile); + } + + private static boolean matches(SegmentState state, String hash, long records) { + return state.tailHash().equals(hash) && state.records() == records + && (records > 0 ? state.present() : !state.present() || "GENESIS".equals(hash)); + } + + private void appendDurably(String line) throws IOException { + Files.writeString(file, line, StandardCharsets.UTF_8, StandardOpenOption.CREATE, + StandardOpenOption.APPEND, StandardOpenOption.WRITE); + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) { channel.force(true); } + } + + private void writePending(PendingAppend pending) throws IOException { + Map value = new LinkedHashMap<>(); + value.put("line", pending.line()); + value.put("rotate", pending.rotate()); + value.put("preActiveHash", pending.preActiveHash()); + value.put("preActiveRecords", pending.preActiveRecords()); + value.put("preRetainedHash", pending.preRetainedHash()); + value.put("preRetainedRecords", pending.preRetainedRecords()); + value.put("postActiveHash", pending.postActiveHash()); + value.put("postActiveRecords", pending.postActiveRecords()); + value.put("postRetainedHash", pending.postRetainedHash()); + value.put("postRetainedRecords", pending.postRetainedRecords()); + writeAtomically(pendingFile, json.writeValueAsBytes(value)); + } + + private static boolean integral(JsonNode object, String name) { + return object.path(name).isIntegralNumber() && object.path(name).canConvertToLong() + && object.path(name).asLong() >= 0; + } + private SegmentState validateOptional(Path selected, String label) throws IOException { if (!Files.exists(selected, LinkOption.NOFOLLOW_LINKS)) return new SegmentState(false, "GENESIS", 0); if (!Files.isRegularFile(selected, LinkOption.NOFOLLOW_LINKS)) @@ -183,24 +261,36 @@ private void writeCheckpoint() throws IOException { checkpoint.put("activeRecords", activeRecords); checkpoint.put("retainedHash", retainedHash); checkpoint.put("retainedRecords", retainedRecords); - Path temporary = Files.createTempFile(checkpointFile.getParent(), "configuration-audit-", ".checkpoint"); + writeAtomically(checkpointFile, json.writeValueAsBytes(checkpoint)); + } + + private static void writeAtomically(Path target, byte[] bytes) throws IOException { + Path temporary = Files.createTempFile(target.getParent(), "configuration-audit-", ".temporary"); try { - Files.write(temporary, json.writeValueAsBytes(checkpoint), StandardOpenOption.TRUNCATE_EXISTING); + Files.write(temporary, bytes, StandardOpenOption.TRUNCATE_EXISTING); try (java.nio.channels.FileChannel channel = java.nio.channels.FileChannel.open(temporary, StandardOpenOption.WRITE)) { channel.force(true); } try { - Files.move(temporary, checkpointFile, StandardCopyOption.ATOMIC_MOVE, + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } catch (java.nio.file.AtomicMoveNotSupportedException e) { - Files.move(temporary, checkpointFile, StandardCopyOption.REPLACE_EXISTING); + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); } } finally { Files.deleteIfExists(temporary); } } + private static void moveReplacing(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } + @Override public synchronized void close() throws IOException { processLock.close(); @@ -208,6 +298,9 @@ public synchronized void close() throws IOException { } private record SegmentState(boolean present, String tailHash, long records) { } + private record PendingAppend(String line, boolean rotate, String preActiveHash, long preActiveRecords, + String preRetainedHash, long preRetainedRecords, String postActiveHash, + long postActiveRecords, String postRetainedHash, long postRetainedRecords) { } @SuppressWarnings("serial") public static final class AuditException extends RuntimeException { diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java index bdb8c1fc..3cc38981 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -31,6 +31,7 @@ public final class InMemoryNodeRegistry implements NodeRegistry { private static final Pattern CAPABILITY = Pattern.compile("[a-z][a-z0-9.-]{0,63}"); private static final Set PLATFORMS = Set.of("BUNGEECORD", "VELOCITY", "BUKKIT"); private static final int MAX_CAPABILITIES = 64; + private static final int MAX_DETECTED_PLUGINS = 128; private static final int MAX_BACKENDS = 4096; private final Map nodes = new ConcurrentHashMap<>(); @@ -60,7 +61,7 @@ public RegistrationResult register(NodeRegistration registration) { StoredNode replacement = new StoredNode(registration.nodeId(), registration.sessionId(), registration.displayName().trim(), registration.platform(), registration.pluginVersion().trim(), registration.protocolVersion(), registration.capabilities(), accepted(registration.capabilities()), - backends, sequence, now, now); + registration.detectedPlugins(), backends, sequence, now, now); result.set(replacement); return replacement; }); @@ -82,7 +83,8 @@ public NodeStatus heartbeat(String nodeId, Heartbeat heartbeat) { requireSession(existing, heartbeat.sessionId()); StoredNode updated = new StoredNode(existing.nodeId, existing.sessionId, existing.displayName, existing.platform, existing.pluginVersion, existing.protocolVersion, heartbeat.capabilities(), - accepted(heartbeat.capabilities()), existing.backends, existing.snapshotSequence, now, now); + accepted(heartbeat.capabilities()), existing.detectedPlugins, existing.backends, + existing.snapshotSequence, now, now); result.set(updated); return updated; }); @@ -109,8 +111,8 @@ public SnapshotResult replacePresence(String nodeId, PresenceSnapshot snapshot) .sorted(Comparator.comparing(BackendServerIdentity::backendId)).toList(); StoredNode updated = new StoredNode(existing.nodeId, existing.sessionId, existing.displayName, existing.platform, existing.pluginVersion, existing.protocolVersion, - existing.advertisedCapabilities, existing.acceptedCapabilities, sorted, snapshot.sequence(), - now, now); + existing.advertisedCapabilities, existing.acceptedCapabilities, existing.detectedPlugins, + sorted, snapshot.sequence(), now, now); applied.set(true); result.set(updated); return updated; @@ -167,8 +169,8 @@ private NodeStatus view(StoredNode node) { // The exact boundary is offline: lastSeen + timeout must be strictly after now. boolean online = clock.instant().isBefore(node.lastSeen.plus(offlineTimeout)); return new NodeStatus(node.nodeId, node.sessionId, node.displayName, node.platform, node.pluginVersion, - node.protocolVersion, node.advertisedCapabilities, node.acceptedCapabilities, node.backends, - node.snapshotSequence, node.lastSeen, node.lastAuthenticatedUpdate, online); + node.protocolVersion, node.advertisedCapabilities, node.acceptedCapabilities, node.detectedPlugins, + node.backends, node.snapshotSequence, node.lastSeen, node.lastAuthenticatedUpdate, online); } private static void validate(NodeRegistration registration) { @@ -184,6 +186,7 @@ private static void validate(NodeRegistration registration) { } validateProtocol(registration.protocolVersion()); validateCapabilities(registration.capabilities(), registration.requiredCapabilities()); + validateDetectedPlugins(registration.detectedPlugins()); } private static void validateSnapshot(PresenceSnapshot snapshot) { @@ -258,6 +261,14 @@ private static void validateCapabilitySet(Set capabilities) { } } + private static void validateDetectedPlugins(Set plugins) { + if (plugins == null || plugins.size() > MAX_DETECTED_PLUGINS || plugins.stream().anyMatch(value -> + value == null || value.isBlank() || value.length() > 100 + || value.chars().anyMatch(Character::isISOControl))) { + throw invalid("detectedPlugins must contain at most 128 names of 1 to 100 characters"); + } + } + private static Set accepted(Set advertised) { Set result = new HashSet<>(advertised); result.retainAll(SUPPORTED_CAPABILITIES); @@ -276,11 +287,13 @@ private static ValidationException invalid(String detail) { private record StoredNode(String nodeId, UUID sessionId, String displayName, String platform, String pluginVersion, int protocolVersion, Set advertisedCapabilities, - Set acceptedCapabilities, List backends, + Set acceptedCapabilities, Set detectedPlugins, + List backends, long snapshotSequence, Instant lastSeen, Instant lastAuthenticatedUpdate) { private StoredNode { advertisedCapabilities = Set.copyOf(advertisedCapabilities); acceptedCapabilities = Set.copyOf(acceptedCapabilities); + detectedPlugins = Set.copyOf(detectedPlugins); backends = List.copyOf(backends); } } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/NodeRegistration.java b/src/main/java/com/bencodez/votingplugin/control/protocol/NodeRegistration.java index f5dce7ea..d63422f1 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/NodeRegistration.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/NodeRegistration.java @@ -6,9 +6,17 @@ /** Immutable proxy registration. Retries for the same node/session are idempotent. */ public record NodeRegistration(String nodeId, UUID sessionId, String displayName, String platform, String pluginVersion, int protocolVersion, Set capabilities, - Set requiredCapabilities) { + Set requiredCapabilities, Set detectedPlugins) { public NodeRegistration { capabilities = capabilities == null ? Set.of() : Set.copyOf(capabilities); requiredCapabilities = requiredCapabilities == null ? Set.of() : Set.copyOf(requiredCapabilities); + detectedPlugins = detectedPlugins == null ? Set.of() : Set.copyOf(detectedPlugins); + } + + public NodeRegistration(String nodeId, UUID sessionId, String displayName, String platform, + String pluginVersion, int protocolVersion, Set capabilities, + Set requiredCapabilities) { + this(nodeId, sessionId, displayName, platform, pluginVersion, protocolVersion, capabilities, + requiredCapabilities, Set.of()); } } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/NodeStatus.java b/src/main/java/com/bencodez/votingplugin/control/protocol/NodeStatus.java index 2a5a67fb..872db44d 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/NodeStatus.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/NodeStatus.java @@ -7,11 +7,20 @@ public record NodeStatus(String nodeId, UUID sessionId, String displayName, String platform, String pluginVersion, int protocolVersion, Set advertisedCapabilities, Set acceptedCapabilities, - List backends, long snapshotSequence, + Set detectedPlugins, List backends, long snapshotSequence, Instant lastSeen, Instant lastAuthenticatedUpdate, boolean online) { public NodeStatus { advertisedCapabilities = Set.copyOf(advertisedCapabilities); acceptedCapabilities = Set.copyOf(acceptedCapabilities); + detectedPlugins = Set.copyOf(detectedPlugins); backends = List.copyOf(backends); } + + public NodeStatus(String nodeId, UUID sessionId, String displayName, String platform, String pluginVersion, + int protocolVersion, Set advertisedCapabilities, Set acceptedCapabilities, + List backends, long snapshotSequence, Instant lastSeen, + Instant lastAuthenticatedUpdate, boolean online) { + this(nodeId, sessionId, displayName, platform, pluginVersion, protocolVersion, advertisedCapabilities, + acceptedCapabilities, Set.of(), backends, snapshotSequence, lastSeen, lastAuthenticatedUpdate, online); + } } diff --git a/src/main/resources/web/app.css b/src/main/resources/web/app.css index 5472ceec..0ba87c4b 100644 --- a/src/main/resources/web/app.css +++ b/src/main/resources/web/app.css @@ -36,6 +36,10 @@ textarea { width: 100%; resize: vertical; margin-bottom: 14px; } .form-grid label { color: #aabbd0; } .form-grid input { display: block; width: 100%; margin-top: 7px; } select { width: 100%; margin-bottom: 16px; } +fieldset { margin: 16px 0; padding: 16px; border: 1px solid #38506f; border-radius: 10px; } +fieldset .check-row { margin: 10px 0; } +#detected-plugins { color: #aabbd0; } +.quick-fields[hidden] { display: none; } code { color: #8ef0b1; } .node h3 { margin-bottom: 6px; } .node p { color: #aabbd0; } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 9fa56a32..cf914f0a 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -27,6 +27,22 @@ const quickName = document.querySelector('#quick-name'); const quickService = document.querySelector('#quick-service'); const quickUrl = document.querySelector('#quick-url'); const quickDelay = document.querySelector('#quick-delay'); +const detectedPlugins = document.querySelector('#detected-plugins'); +const quickRewardScope = document.querySelector('#quick-reward-scope'); +const quickCommand = document.querySelector('#quick-command'); +const quickMessage = document.querySelector('#quick-message'); +const quickCommandSuggestions = document.querySelector('#quick-command-suggestions'); +const quickProcessRewards = document.querySelector('#quick-process-rewards'); +const quickAutoSites = document.querySelector('#quick-auto-sites'); +const quickExtraCheck = document.querySelector('#quick-extra-check'); +const quickCountFake = document.querySelector('#quick-count-fake'); +const quickHideSiteWarning = document.querySelector('#quick-hide-site-warning'); +const quickDisableUpdates = document.querySelector('#quick-disable-updates'); +const quickPartyVotes = document.querySelector('#quick-party-votes'); +const quickPartyCommand = document.querySelector('#quick-party-command'); +const quickPartyBroadcast = document.querySelector('#quick-party-broadcast'); +const quickPartyAll = document.querySelector('#quick-party-all'); +const quickPartyOnline = document.querySelector('#quick-party-online'); const previewQuickSetup = document.querySelector('#preview-quick-setup'); const applyQuickSetup = document.querySelector('#apply-quick-setup'); const quickOperationStatus = document.querySelector('#quick-operation-status'); @@ -39,7 +55,9 @@ let approvedPreview = null; let approvedFilePreview = null; let approvedQuickPreview = null; let nodeCapabilities = new Map(); +let nodePlugins = new Map(); let inputGeneration = 0; +let authenticationGeneration = 0; function text(element, value) { element.textContent = value; @@ -87,11 +105,15 @@ function nodeCard(node) { approvedFilePreview = null; approvedQuickPreview = null; inputGeneration++; + updatePluginSuggestions(); updateConfigurationButtons(); }); selector.append(checkbox, title); const meta = text(document.createElement('p'), `${node.platform} · VotingPlugin ${node.pluginVersion} · ${node.online ? 'online' : 'offline'} · ${node.acceptedCapabilities.filter(value => value.startsWith('config.')).join(', ') || 'discovery only'}`); + const plugins = Array.isArray(node.detectedPlugins) ? node.detectedPlugins : []; + const pluginMeta = text(document.createElement('p'), plugins.length + ? `Detected plugins: ${plugins.join(', ')}` : 'No Bukkit plugin inventory reported.'); const list = document.createElement('ul'); const backends = Array.isArray(node.backends) ? node.backends : []; if (backends.length === 0) { @@ -99,7 +121,7 @@ function nodeCard(node) { } else { backends.forEach(backend => list.append(backendCard(backend))); } - article.append(selector, meta, list); + article.append(selector, meta, pluginMeta, list); return article; } @@ -129,6 +151,38 @@ function clearApprovals() { updateConfigurationButtons(); } +function updateQuickFields() { + document.querySelectorAll('.quick-fields').forEach(group => { + group.hidden = !group.dataset.presets.split(' ').includes(quickPreset.value); + }); + quickName.closest('.quick-fields').hidden = !['proxy-backend', 'vote-site', 'easy-reward'].includes(quickPreset.value); +} + +function updatePluginSuggestions() { + const plugins = new Set(targets('config.quick-setup.v1').flatMap(node => nodePlugins.get(node) || []) + .map(name => name.toLowerCase())); + const names = [...plugins].sort(); + text(detectedPlugins, names.length ? `Detected on selected nodes: ${names.join(', ')}` + : 'No plugin inventory is available for the selected nodes; generic commands are still available.'); + const suggestions = [ + ['give %player% diamond 1', 'Minecraft item'], + ['xp add %player% 5 levels', 'Minecraft experience'] + ]; + if ([...plugins].some(name => name === 'essentials' || name === 'essentialsx')) { + suggestions.push(['eco give %player% 100', 'Essentials economy']); + } + if (plugins.has('cmi')) suggestions.push(['money give %player% 100', 'CMI economy']); + if (plugins.has('luckperms')) { + suggestions.push(['lp user %player% permission set example.permission true', 'LuckPerms permission']); + } + quickCommandSuggestions.replaceChildren(...suggestions.map(([value, label]) => { + const option = document.createElement('option'); + option.value = value; + option.label = label; + return option; + })); +} + async function authorized(path, options = {}) { const method = (options.method || 'GET').toUpperCase(); const response = await fetch(path, { @@ -197,6 +251,8 @@ async function loadNodes() { body.items.forEach(node => nodes.append(nodeCard(node))); } nodeCapabilities = new Map(body.items.map(node => [node.nodeId, node.online ? node.acceptedCapabilities : []])); + nodePlugins = new Map(body.items.map(node => [node.nodeId, node.online && Array.isArray(node.detectedPlugins) + ? node.detectedPlugins : []])); const invalidRoutingApproval = approvedPreview && !approvedPreview.nodeIds.every(node => nodeCapabilities.get(node)?.includes('config.proxy-routing.v1')); const invalidFileApproval = approvedFilePreview && !approvedFilePreview.nodeIds.every(node => @@ -221,6 +277,7 @@ async function loadNodes() { text(operationStatus, 'The selected proxies changed during refresh. Preview again before apply.'); } selectedNodes = filteredSelection; + updatePluginSuggestions(); updateConfigurationButtons(); const first = body.items.length === 0 ? 0 : pageOffset + 1; const last = pageOffset + body.items.length; @@ -240,6 +297,7 @@ async function loadNodes() { form.addEventListener('submit', async event => { event.preventDefault(); + const loginGeneration = ++authenticationGeneration; const password = passwordInput.value; passwordInput.value = ''; try { @@ -249,6 +307,7 @@ form.addEventListener('submit', async event => { }); const body = await response.json(); if (!response.ok) throw new Error(body?.error?.message || 'Authentication failed.'); + if (loginGeneration !== authenticationGeneration) return; authenticated = true; csrfToken = body.csrfToken; approvedPreview = null; @@ -265,6 +324,7 @@ form.addEventListener('submit', async event => { }); logout.addEventListener('click', async () => { + authenticationGeneration++; try { await authorized('/api/v1/auth/logout', {method: 'POST'}); } catch (_) { /* Expired is logged out too. */ } authenticated = false; csrfToken = ''; @@ -275,6 +335,7 @@ logout.addEventListener('click', async () => { logout.hidden = true; selectedNodes.clear(); nodeCapabilities.clear(); + nodePlugins.clear(); nodes.replaceChildren(); nodes.classList.add('empty'); text(nodes, 'Authenticate to view the network.'); @@ -283,10 +344,12 @@ logout.addEventListener('click', async () => { }); async function restoreSession() { + const restoreGeneration = authenticationGeneration; try { const response = await fetch('/api/v1/auth/session', {cache: 'no-store', credentials: 'same-origin'}); if (!response.ok || response.status === 204) return; const body = await response.json(); + if (restoreGeneration !== authenticationGeneration) return; authenticated = true; csrfToken = body.csrfToken; logout.hidden = false; @@ -350,9 +413,10 @@ readFileConfiguration.addEventListener('click', async () => { nodeIds: targets('config.files.v1'), configuration: {domain: 'file', fileName: configurationFile.value} }, fileOperationStatus); - const successful = Object.values(operation.results).filter(result => result.success); - if (successful.length) { - configurationContent.value = successful[0].configuration.content; + const contentResult = Object.values(operation.results).find(result => + result.success && result.configuration?.content != null); + if (contentResult) { + configurationContent.value = contentResult.configuration.content; text(fileOperationStatus, operationSummary(operation)); inputGeneration++; updateConfigurationButtons(); @@ -395,10 +459,21 @@ applyFileConfiguration.addEventListener('click', async () => { function quickOptions() { if (quickPreset.value === 'standalone') return {}; if (quickPreset.value === 'proxy-backend') return {server: quickName.value.trim(), method: 'PLUGINMESSAGING'}; - return { - name: quickName.value.trim(), displayName: quickName.value.trim(), serviceSite: quickService.value.trim(), - voteUrl: quickUrl.value.trim(), voteDelay: quickDelay.value.trim(), priority: '5', material: 'DIAMOND' + if (quickPreset.value === 'vote-site') return { + name: quickName.value.trim(), displayName: quickName.value.trim(), serviceSite: quickService.value.trim(), + voteUrl: quickUrl.value.trim(), voteDelay: quickDelay.value.trim(), priority: '5', material: 'DIAMOND' + }; + if (quickPreset.value === 'easy-reward') return {scope: quickRewardScope.value, + name: quickName.value.trim(), command: quickCommand.value.trim(), message: quickMessage.value.trim()}; + if (quickPreset.value === 'common-settings') return { + processRewards: String(quickProcessRewards.checked), autoCreateVoteSites: String(quickAutoSites.checked), + extraAllSitesCheck: String(quickExtraCheck.checked), countFakeVotes: String(quickCountFake.checked), + disableNoServiceSiteMessage: String(quickHideSiteWarning.checked), + disableUpdateChecking: String(quickDisableUpdates.checked) }; + return {votesRequired: quickPartyVotes.value, command: quickPartyCommand.value.trim(), + broadcast: quickPartyBroadcast.value.trim(), giveAllPlayers: String(quickPartyAll.checked), + onlineOnly: String(quickPartyOnline.checked)}; } previewQuickSetup.addEventListener('click', async () => { @@ -433,8 +508,11 @@ applyQuickSetup.addEventListener('click', async () => { } catch (error) { text(quickOperationStatus, error.message); } }); -[configurationFile, configurationContent, quickPreset, quickName, quickService, quickUrl, quickDelay] - .forEach(field => field.addEventListener('input', clearApprovals)); +[configurationFile, configurationContent, quickName, quickService, quickUrl, quickDelay, quickRewardScope, + quickCommand, quickMessage, quickProcessRewards, quickAutoSites, quickExtraCheck, quickCountFake, + quickHideSiteWarning, quickDisableUpdates, quickPartyVotes, quickPartyCommand, quickPartyBroadcast, + quickPartyAll, quickPartyOnline].forEach(field => field.addEventListener('input', clearApprovals)); +quickPreset.addEventListener('input', () => { updateQuickFields(); clearApprovals(); }); refresh.addEventListener('click', loadNodes); previousPage.addEventListener('click', () => { pageOffset = Math.max(0, pageOffset - PAGE_SIZE); @@ -445,4 +523,6 @@ nextPage.addEventListener('click', () => { loadNodes(); }); loadHealth(); +updateQuickFields(); +updatePluginSuggestions(); restoreSession(); diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index ece187fe..56a8cb19 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -106,13 +106,41 @@

Quick setups

+ + + -
+

Select Bukkit nodes to see detected plugins and command suggestions.

+
+
+
+
+ + + + +
+
+ Common settings + + + + + + +
+
+ + + + + +
diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index e81ebe2a..462764b7 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -9,6 +9,8 @@ import com.bencodez.votingplugin.control.protocol.NodeRegistration; import com.bencodez.votingplugin.control.protocol.ProxyRoutingConfiguration; import com.bencodez.votingplugin.control.protocol.ManagedConfiguration; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.file.Path; import java.nio.file.Files; import java.time.Clock; @@ -17,6 +19,8 @@ import java.time.ZoneOffset; import java.time.ZoneId; import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Set; import java.util.UUID; import org.junit.jupiter.api.Test; @@ -130,6 +134,41 @@ class ConfigurationOperationsTest { assertThrows(java.io.IOException.class, () -> new ConfigurationAuditLog(directory, clock)); } + @Test void auditPendingTransactionRecoversRecordBeforeCheckpointCrash() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + Path active = directory.resolve("configuration-audit.jsonl"); + Path checkpoint = directory.resolve("configuration-audit.checkpoint"); + ObjectMapper json = new ObjectMapper(); + try (ConfigurationAuditLog log = new ConfigurationAuditLog(directory, clock)) { + log.append("FIRST", UUID.randomUUID(), "proxy-a", "OK"); + } + byte[] before = Files.readAllBytes(checkpoint); + try (ConfigurationAuditLog log = new ConfigurationAuditLog(directory, clock)) { + log.append("SECOND", UUID.randomUUID(), "proxy-a", "OK"); + } + byte[] after = Files.readAllBytes(checkpoint); + List lines = Files.readAllLines(active); + JsonNode pre = json.readTree(before); + JsonNode post = json.readTree(after); + Map pending = new LinkedHashMap<>(); + pending.put("line", lines.get(1) + System.lineSeparator()); + pending.put("rotate", false); + pending.put("preActiveHash", pre.path("activeHash").asText()); + pending.put("preActiveRecords", pre.path("activeRecords").asLong()); + pending.put("preRetainedHash", pre.path("retainedHash").asText()); + pending.put("preRetainedRecords", pre.path("retainedRecords").asLong()); + pending.put("postActiveHash", post.path("activeHash").asText()); + pending.put("postActiveRecords", post.path("activeRecords").asLong()); + pending.put("postRetainedHash", post.path("retainedHash").asText()); + pending.put("postRetainedRecords", post.path("retainedRecords").asLong()); + Files.write(directory.resolve("configuration-audit.pending"), json.writeValueAsBytes(pending)); + Files.write(checkpoint, before); + + try (ConfigurationAuditLog ignored = new ConfigurationAuditLog(directory, clock)) { } + assertEquals(json.readTree(after), json.readTree(Files.readAllBytes(checkpoint))); + assertEquals(false, Files.exists(directory.resolve("configuration-audit.pending"))); + } + @Test void auditLogRejectsASecondProcessWriter() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); try (ConfigurationAuditLog first = new ConfigurationAuditLog(directory, clock)) { diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 3bba6441..3d09dec4 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -63,6 +63,9 @@ class ControlHttpServerTest { assertEquals(200, script.statusCode()); assertTrue(script.body().contains("offset=${pageOffset}&limit=${PAGE_SIZE}")); assertTrue(script.body().contains("nextPage.addEventListener")); + assertTrue(script.body().contains("result.configuration?.content != null")); + assertTrue(script.body().contains("authenticationGeneration")); + assertTrue(web.body().contains("Easy vote reward")); assertTrue(script.body().contains("filteredSelection.size !== selectedNodes.size")); assertTrue(script.body().contains("previewGeneration === inputGeneration")); assertTrue(script.body().contains("approvedPreview.nodeIds.every")); @@ -95,6 +98,7 @@ class ControlHttpServerTest { assertEquals(200, send("PUT", "/api/v1/nodes/proxy-a/presence", snapshot, nodeToken).statusCode()); JsonNode listed = json.readTree(get("/api/v1/nodes?offset=0&limit=10", adminToken).body()); assertEquals("lobby", listed.at("/items/0/backends/0/backendId").asText()); + assertTrue(listed.at("/items/0/detectedPlugins").toString().contains("LuckPerms")); assertTrue(listed.at("/items/0/online").asBoolean()); } @@ -248,7 +252,8 @@ class ControlHttpServerTest { private String registration() { return "{\"nodeId\":\"proxy-a\",\"sessionId\":\"" + SESSION + "\",\"displayName\":\"Proxy A\"," + "\"platform\":\"VELOCITY\",\"pluginVersion\":\"7.1.2\",\"protocolVersion\":1," - + "\"capabilities\":[\"presence.snapshot\"],\"requiredCapabilities\":[]}"; + + "\"capabilities\":[\"presence.snapshot\"],\"requiredCapabilities\":[]," + + "\"detectedPlugins\":[\"LuckPerms\",\"Essentials\"]}"; } private HttpResponse get(String path, String token) throws Exception { From ef07e3c2d8414e34f9e1d1021f433fd3b90d3be4 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 25 Aug 2026 17:06:34 -0600 Subject: [PATCH 16/42] Fix latest Control review races --- .../control/domain/ConfigurationAuditLog.java | 68 +++++++++++++++++-- .../domain/ConfigurationOperations.java | 20 +++++- .../control/domain/InMemoryNodeRegistry.java | 5 +- .../control/domain/NodeRegistry.java | 3 +- .../control/http/ControlHttpServer.java | 5 +- .../domain/ConfigurationOperationsTest.java | 29 +++++++- .../control/http/ControlHttpServerTest.java | 17 +++++ 7 files changed, 134 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java index c8c163de..109584cc 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java @@ -92,8 +92,9 @@ public synchronized void append(String action, UUID operationId, String nodeId, String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical)); core.put("hash", hash); String line = json.writeValueAsString(core) + System.lineSeparator(); + long preActiveBytes = Files.exists(file, LinkOption.NOFOLLOW_LINKS) ? Files.size(file) : 0; PendingAppend pending = new PendingAppend(line, rotate, previousHash, activeRecords, retainedHash, - retainedRecords, hash, rotate ? 1 : activeRecords + 1, + retainedRecords, preActiveBytes, hash, rotate ? 1 : activeRecords + 1, rotate ? previousHash : retainedHash, rotate ? activeRecords : retainedRecords); writePending(pending); completePending(pending); @@ -109,15 +110,17 @@ private void recoverPending() throws IOException { } try { JsonNode stored = json.readTree(Files.readAllBytes(pendingFile)); - if (stored == null || !stored.isObject() || stored.size() != 10 + if (stored == null || !stored.isObject() || stored.size() != 11 || !stored.path("rotate").isBoolean() || !integral(stored, "preActiveRecords") || !integral(stored, "preRetainedRecords") + || !integral(stored, "preActiveBytes") || !integral(stored, "postActiveRecords") || !integral(stored, "postRetainedRecords")) { throw new IOException("Configuration audit pending transaction is invalid"); } completePending(new PendingAppend(stored.path("line").asText(), stored.path("rotate").asBoolean(), stored.path("preActiveHash").asText(), stored.path("preActiveRecords").asLong(), stored.path("preRetainedHash").asText(), stored.path("preRetainedRecords").asLong(), + stored.path("preActiveBytes").asLong(), stored.path("postActiveHash").asText(), stored.path("postActiveRecords").asLong(), stored.path("postRetainedHash").asText(), stored.path("postRetainedRecords").asLong())); } catch (IOException e) { @@ -129,7 +132,12 @@ private void recoverPending() throws IOException { private void completePending(PendingAppend pending) throws IOException { SegmentState retained = validateOptional(previousFile, "Retained configuration audit log"); - SegmentState active = validateOptional(file, "Configuration audit log"); + SegmentState active; + try { + active = validateOptional(file, "Configuration audit log"); + } catch (IOException invalidActive) { + active = repairTornActive(pending, retained, invalidActive); + } if (matches(active, pending.postActiveHash(), pending.postActiveRecords()) && matches(retained, pending.postRetainedHash(), pending.postRetainedRecords())) { // The record is durable; only checkpoint publication or cleanup was interrupted. @@ -154,6 +162,38 @@ && matches(retained, pending.postRetainedHash(), pending.postRetainedRecords())) Files.delete(pendingFile); } + private SegmentState repairTornActive(PendingAppend pending, SegmentState retained, + IOException invalidActive) throws IOException { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw invalidActive; + byte[] actual = Files.readAllBytes(file); + byte[] intended = pending.line().getBytes(StandardCharsets.UTF_8); + long baseLong = pending.rotate() ? 0 : pending.preActiveBytes(); + if (baseLong > Integer.MAX_VALUE || actual.length <= baseLong + || actual.length >= baseLong + intended.length) throw invalidActive; + int base = (int) baseLong; + if (pending.rotate()) { + if (!matches(retained, pending.postRetainedHash(), pending.postRetainedRecords())) throw invalidActive; + } else { + SegmentState prefix = validateBytes(java.util.Arrays.copyOf(actual, base)); + if (!matches(prefix, pending.preActiveHash(), pending.preActiveRecords()) + || !matches(retained, pending.preRetainedHash(), pending.preRetainedRecords())) { + throw invalidActive; + } + } + for (int index = base; index < actual.length; index++) { + if (actual[index] != intended[index - base]) throw invalidActive; + } + if (base == 0) { + Files.delete(file); + } else { + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) { + channel.truncate(base); + channel.force(true); + } + } + return validateOptional(file, "Configuration audit log"); + } + private static boolean matches(SegmentState state, String hash, long records) { return state.tailHash().equals(hash) && state.records() == records && (records > 0 ? state.present() : !state.present() || "GENESIS".equals(hash)); @@ -173,6 +213,7 @@ private void writePending(PendingAppend pending) throws IOException { value.put("preActiveRecords", pending.preActiveRecords()); value.put("preRetainedHash", pending.preRetainedHash()); value.put("preRetainedRecords", pending.preRetainedRecords()); + value.put("preActiveBytes", pending.preActiveBytes()); value.put("postActiveHash", pending.postActiveHash()); value.put("postActiveRecords", pending.postActiveRecords()); value.put("postRetainedHash", pending.postRetainedHash()); @@ -195,9 +236,25 @@ private SegmentState validateOptional(Path selected, String label) throws IOExce } private SegmentState validateExisting(Path selected) throws IOException { + return validateLines(Files.readAllLines(selected, StandardCharsets.UTF_8)); + } + + private SegmentState validateBytes(byte[] bytes) throws IOException { + try { + String content = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) + .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) + .decode(java.nio.ByteBuffer.wrap(bytes)).toString(); + return validateLines(content.lines().toList()); + } catch (java.nio.charset.CharacterCodingException e) { + throw new IOException("Configuration audit chain is invalid", e); + } + } + + private SegmentState validateLines(Iterable lines) throws IOException { String expectedPrevious = "GENESIS"; long records = 0; - for (String line : Files.readAllLines(selected, StandardCharsets.UTF_8)) { + for (String line : lines) { if (line.isBlank()) continue; try { JsonNode stored = json.readTree(line); @@ -299,7 +356,8 @@ public synchronized void close() throws IOException { private record SegmentState(boolean present, String tailHash, long records) { } private record PendingAppend(String line, boolean rotate, String preActiveHash, long preActiveRecords, - String preRetainedHash, long preRetainedRecords, String postActiveHash, + String preRetainedHash, long preRetainedRecords, long preActiveBytes, + String postActiveHash, long postActiveRecords, String postRetainedHash, long postRetainedRecords) { } @SuppressWarnings("serial") diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 00325752..6047c264 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -100,10 +100,10 @@ public synchronized OperationView get(UUID id) { } public synchronized ConfigurationTask claim(String nodeId, UUID sessionId) { - return registry.withSession(nodeId, sessionId, () -> claimCurrentSession(nodeId)); + return registry.withSession(nodeId, sessionId, node -> claimCurrentSession(nodeId, node)); } - private ConfigurationTask claimCurrentSession(String nodeId) { + private ConfigurationTask claimCurrentSession(String nodeId, NodeStatus node) { prune(); Instant now = clock.instant(); for (StoredOperation operation : operations.values()) { @@ -111,6 +111,15 @@ private ConfigurationTask claimCurrentSession(String nodeId) { Instant leased = operation.leasedAt.get(nodeId); if ("QUEUED".equals(state) || ("IN_PROGRESS".equals(state) && leased != null && !now.isBefore(leased.plus(LEASE)))) { + if (!node.online() || !node.acceptedCapabilities().contains(operation.configuration.capability())) { + audit.append("TASK_CANCELLED", operation.id, nodeId, "CAPABILITY_LOST"); + operation.results.put(nodeId, new ConfigurationTaskResult(sessionId(node), false, + "CAPABILITY_LOST", "Node no longer accepts this configuration capability", null, + (ManagedConfiguration) null, List.of(), false, false)); + operation.states.put(nodeId, "COMPLETE"); + operation.leasedAt.remove(nodeId); + continue; + } operation.states.put(nodeId, "IN_PROGRESS"); operation.leasedAt.put(nodeId, now); try { @@ -129,7 +138,12 @@ private ConfigurationTask claimCurrentSession(String nodeId) { public synchronized OperationView complete(UUID operationId, String nodeId, ConfigurationTaskResult result) { validateResult(result); - return registry.withSession(nodeId, result.sessionId(), () -> completeCurrentSession(operationId, nodeId, result)); + return registry.withSession(nodeId, result.sessionId(), + ignored -> completeCurrentSession(operationId, nodeId, result)); + } + + private static UUID sessionId(NodeStatus node) { + return node.sessionId(); } private OperationView completeCurrentSession(UUID operationId, String nodeId, ConfigurationTaskResult result) { diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java index 3cc38981..d1fa322e 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -149,7 +149,8 @@ public void requireSession(String nodeId, UUID sessionId) { /** Runs one task mutation while replacement registration for this node is excluded. */ @Override - public T withSession(String nodeId, UUID sessionId, java.util.function.Supplier action) { + public T withSession(String nodeId, UUID sessionId, + java.util.function.Function action) { validateId(nodeId, "nodeId"); validateSession(sessionId); Objects.requireNonNull(action, "action"); @@ -158,7 +159,7 @@ public T withSession(String nodeId, UUID sessionId, java.util.function.Suppl nodes.computeIfPresent(nodeId, (ignored, existing) -> { found.set(true); requireSession(existing, sessionId); - result.set(action.get()); + result.set(action.apply(view(existing))); return existing; }); if (!found.get()) throw new ValidationException("NODE_NOT_FOUND", "Node is not registered", List.of()); diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java index 1dd96ecc..0c846e3e 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java @@ -13,7 +13,8 @@ public interface NodeRegistry { List list(int offset, int limit); NodeStatus find(String nodeId); void requireSession(String nodeId, java.util.UUID sessionId); - T withSession(String nodeId, java.util.UUID sessionId, java.util.function.Supplier action); + T withSession(String nodeId, java.util.UUID sessionId, + java.util.function.Function action); record RegistrationResult(NodeStatus node, boolean created) { } record SnapshotResult(NodeStatus node, boolean applied) { } diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index e1bb448b..50b428e9 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -211,7 +211,10 @@ private void route(HttpExchange exchange) throws IOException { requireMethod(exchange, "POST"); PasswordRequest request = read(exchange, PasswordRequest.class); requireRequest(request); - if (!passwordVerifications.tryAcquire()) { + try { + passwordVerifications.acquire(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); throw new AuthenticationException(true); } String credentialRevision; diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 462764b7..c748b1be 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -9,6 +9,7 @@ import com.bencodez.votingplugin.control.protocol.NodeRegistration; import com.bencodez.votingplugin.control.protocol.ProxyRoutingConfiguration; import com.bencodez.votingplugin.control.protocol.ManagedConfiguration; +import com.bencodez.votingplugin.control.protocol.Heartbeat; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.file.Path; @@ -134,7 +135,7 @@ class ConfigurationOperationsTest { assertThrows(java.io.IOException.class, () -> new ConfigurationAuditLog(directory, clock)); } - @Test void auditPendingTransactionRecoversRecordBeforeCheckpointCrash() throws Exception { + @Test void auditPendingTransactionRecoversTornRecordBeforeCheckpointCrash() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); Path active = directory.resolve("configuration-audit.jsonl"); Path checkpoint = directory.resolve("configuration-audit.checkpoint"); @@ -157,15 +158,22 @@ class ConfigurationOperationsTest { pending.put("preActiveRecords", pre.path("activeRecords").asLong()); pending.put("preRetainedHash", pre.path("retainedHash").asText()); pending.put("preRetainedRecords", pre.path("retainedRecords").asLong()); + byte[] firstLine = (lines.get(0) + System.lineSeparator()).getBytes(java.nio.charset.StandardCharsets.UTF_8); + pending.put("preActiveBytes", firstLine.length); pending.put("postActiveHash", post.path("activeHash").asText()); pending.put("postActiveRecords", post.path("activeRecords").asLong()); pending.put("postRetainedHash", post.path("retainedHash").asText()); pending.put("postRetainedRecords", post.path("retainedRecords").asLong()); Files.write(directory.resolve("configuration-audit.pending"), json.writeValueAsBytes(pending)); Files.write(checkpoint, before); + byte[] secondLine = (lines.get(1) + System.lineSeparator()).getBytes(java.nio.charset.StandardCharsets.UTF_8); + byte[] torn = java.util.Arrays.copyOf(firstLine, firstLine.length + secondLine.length / 2); + System.arraycopy(secondLine, 0, torn, firstLine.length, secondLine.length / 2); + Files.write(active, torn); try (ConfigurationAuditLog ignored = new ConfigurationAuditLog(directory, clock)) { } assertEquals(json.readTree(after), json.readTree(Files.readAllBytes(checkpoint))); + assertEquals(2, Files.readAllLines(active).size()); assertEquals(false, Files.exists(directory.resolve("configuration-audit.pending"))); } @@ -222,6 +230,25 @@ class ConfigurationOperationsTest { ManagedConfiguration.proxy(new ProxyRoutingConfiguration(false, List.of())))).code()); } + @Test void claimCancelsTaskWhenCurrentSessionLostItsCapability() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID session = UUID.randomUUID(); + registry.register(new NodeRegistration("backend-a", session, "Backend A", "BUKKIT", "test", 1, + Set.of(ConfigurationOperations.FILE_CAPABILITY), Set.of(ConfigurationOperations.FILE_CAPABILITY))); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + UUID operation = operations.createRead(List.of("backend-a"), + ManagedConfiguration.file("Config.yml", null)).operationId(); + + registry.heartbeat("backend-a", new Heartbeat(session, 1, Set.of("discovery.read"), Set.of())); + + assertEquals(null, operations.claim("backend-a", session)); + ConfigurationOperations.OperationView view = operations.get(operation); + assertEquals("COMPLETED_WITH_ERRORS", view.state()); + assertEquals("CAPABILITY_LOST", view.results().get("backend-a").code()); + } + @Test void fileOperationRetentionAndMultiNodeContentsAreBounded() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 3d09dec4..2564933c 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -197,6 +197,23 @@ class ControlHttpServerTest { Map.of("Cookie", secondCookie)).statusCode()); } + @Test void concurrentValidPasswordAttemptsQueueBehindVerificationLimit() throws Exception { + credentials.setWebPassword("a-secure-web-password".toCharArray()); + java.util.List>> attempts = + java.util.stream.IntStream.range(0, 6).mapToObj(ignored -> + java.util.concurrent.CompletableFuture.supplyAsync(() -> { + try { + return send("POST", "/api/v1/auth/login", + "{\"password\":\"a-secure-web-password\"}", null); + } catch (Exception e) { + throw new java.util.concurrent.CompletionException(e); + } + })).toList(); + for (java.util.concurrent.CompletableFuture> attempt : attempts) { + assertEquals(200, attempt.join().statusCode()); + } + } + @Test void malformedEmptyNullDuplicateNestedOversizedAndLongPayloadsAreDeterministic() throws Exception { assertError(send("POST", "/api/v1/nodes/register", "{", nodeToken), 400, "MALFORMED_JSON"); assertError(send("POST", "/api/v1/nodes/register", "", nodeToken), 400, "MALFORMED_JSON"); From 0cd0ebb1488dfadef0be300b1977e4119297bd4e Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 25 Aug 2026 17:38:44 -0600 Subject: [PATCH 17/42] Fix latest authentication and audit findings --- .../control/domain/ConfigurationAuditLog.java | 12 ++++ .../control/http/ControlHttpServer.java | 70 +++++++++++++------ src/main/resources/web/app.js | 10 +++ .../domain/ConfigurationOperationsTest.java | 40 +++++++++++ .../control/http/ControlHttpServerTest.java | 20 ++++++ 5 files changed, 132 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java index 109584cc..6a995957 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java @@ -138,6 +138,12 @@ private void completePending(PendingAppend pending) throws IOException { } catch (IOException invalidActive) { active = repairTornActive(pending, retained, invalidActive); } + if (matches(active, pending.postActiveHash(), pending.postActiveRecords()) + && matches(retained, pending.postRetainedHash(), pending.postRetainedRecords()) + && !hasCompletePendingBytes(pending)) { + active = repairTornActive(pending, retained, + new IOException("Configuration audit pending record is incomplete")); + } if (matches(active, pending.postActiveHash(), pending.postActiveRecords()) && matches(retained, pending.postRetainedHash(), pending.postRetainedRecords())) { // The record is durable; only checkpoint publication or cleanup was interrupted. @@ -194,6 +200,12 @@ private SegmentState repairTornActive(PendingAppend pending, SegmentState retain return validateOptional(file, "Configuration audit log"); } + private boolean hasCompletePendingBytes(PendingAppend pending) throws IOException { + long base = pending.rotate() ? 0 : pending.preActiveBytes(); + return Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) + && Files.size(file) == base + pending.line().getBytes(StandardCharsets.UTF_8).length; + } + private static boolean matches(SegmentState state, String hash, long records) { return state.tailHash().equals(hash) && state.records() == records && (records > 0 ? state.present() : !state.present() || "GENESIS".equals(hash)); diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 50b428e9..44daa6ba 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -44,10 +44,10 @@ import java.util.Objects; import java.util.UUID; import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import java.util.concurrent.Semaphore; /** Bounded HTTP adapter. Node writes are enrolled; node listings require the local admin credential. */ public final class ControlHttpServer implements AutoCloseable { @@ -75,10 +75,10 @@ public final class ControlHttpServer implements AutoCloseable { private final CredentialStore credentials; private final ConfigurationOperations configurationOperations; private final ThreadPoolExecutor executor; + private final ThreadPoolExecutor passwordExecutor; private final AuthFailureLimiter authLimiter; private final WebSessionStore webSessions; private final boolean secureCookies; - private final Semaphore passwordVerifications = new Semaphore(2, true); public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials) throws IOException { @@ -131,6 +131,13 @@ public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, Contr }; executor = new ThreadPoolExecutor(2, 8, 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(32), threads, new ThreadPoolExecutor.AbortPolicy()); + ThreadFactory passwordThreads = runnable -> { + Thread thread = new Thread(runnable, "votingplugin-control-password"); + thread.setDaemon(true); + return thread; + }; + passwordExecutor = new ThreadPoolExecutor(2, 2, 0, TimeUnit.SECONDS, + new ArrayBlockingQueue<>(32), passwordThreads, new ThreadPoolExecutor.AbortPolicy()); server.setExecutor(executor); } @@ -146,8 +153,10 @@ public int port() { public void close() { server.stop(0); executor.shutdownNow(); + passwordExecutor.shutdownNow(); try { executor.awaitTermination(2, TimeUnit.SECONDS); + passwordExecutor.awaitTermination(2, TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } @@ -159,8 +168,11 @@ public void close() { } private void handle(HttpExchange exchange) throws IOException { + boolean deferred = false; try { route(exchange); + } catch (DeferredResponseException ignored) { + deferred = true; } catch (AuthenticationException e) { exchange.getResponseHeaders().set("WWW-Authenticate", "Bearer"); error(exchange, e.rateLimited ? 429 : 401, e.rateLimited ? "AUTH_RATE_LIMITED" : "UNAUTHORIZED", @@ -189,7 +201,7 @@ private void handle(HttpExchange exchange) throws IOException { } catch (RuntimeException e) { error(exchange, 500, "INTERNAL_ERROR", "Request could not be completed", List.of()); } finally { - exchange.close(); + if (!deferred) exchange.close(); } } @@ -212,25 +224,11 @@ private void route(HttpExchange exchange) throws IOException { PasswordRequest request = read(exchange, PasswordRequest.class); requireRequest(request); try { - passwordVerifications.acquire(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + passwordExecutor.execute(() -> handlePasswordLogin(exchange, request)); + } catch (RejectedExecutionException e) { throw new AuthenticationException(true); } - String credentialRevision; - try { - credentialRevision = credentials.authenticateWebPassword(request.password()); - } finally { - passwordVerifications.release(); - } - if (credentialRevision == null) { - authenticationFailed(); - } - webSessions.remove(cookie(exchange, SESSION_COOKIE)); - WebSessionStore.Session session = webSessions.create(credentialRevision); - setSessionCookie(exchange, session.id(), false); - send(exchange, 200, Map.of("csrfToken", session.csrfToken())); - return; + throw new DeferredResponseException(); } if (AUTH_SESSION.equals(path)) { requireMethod(exchange, "GET"); @@ -351,6 +349,36 @@ private void route(HttpExchange exchange) throws IOException { error(exchange, 404, "NOT_FOUND", "Endpoint not found", List.of()); } + private void handlePasswordLogin(HttpExchange exchange, PasswordRequest request) { + try { + String credentialRevision = credentials.authenticateWebPassword(request.password()); + if (credentialRevision == null) { + authenticationFailed(); + } + webSessions.remove(cookie(exchange, SESSION_COOKIE)); + WebSessionStore.Session session = webSessions.create(credentialRevision); + setSessionCookie(exchange, session.id(), false); + send(exchange, 200, Map.of("csrfToken", session.csrfToken())); + } catch (AuthenticationException e) { + exchange.getResponseHeaders().set("WWW-Authenticate", "Bearer"); + try { + error(exchange, e.rateLimited ? 429 : 401, + e.rateLimited ? "AUTH_RATE_LIMITED" : "UNAUTHORIZED", + e.rateLimited ? "Too many authentication failures" : "Authentication failed", List.of()); + } catch (IOException ignored) { + // The client disconnected while password verification was in progress. + } + } catch (IOException | RuntimeException e) { + try { + error(exchange, 500, "INTERNAL_ERROR", "Request could not be completed", List.of()); + } catch (IOException ignored) { + // The client disconnected while password verification was in progress. + } + } finally { + exchange.close(); + } + } + private T read(HttpExchange exchange, Class type) throws IOException { String contentType = exchange.getRequestHeaders().getFirst("Content-Type"); if (contentType == null || !contentType.toLowerCase(Locale.ROOT).startsWith("application/json")) { @@ -590,6 +618,8 @@ private AuthenticationException(boolean rateLimited) { private static final class CsrfException extends RuntimeException { } @SuppressWarnings("serial") private static final class ResponseCompleteException extends RuntimeException { } + @SuppressWarnings("serial") + private static final class DeferredResponseException extends RuntimeException { } private record WebResource(String classpath, String contentType) { } private record PasswordRequest(String password) { } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index cf914f0a..c227c539 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -3,6 +3,7 @@ const health = document.querySelector('#health'); const form = document.querySelector('#auth-form'); const passwordInput = document.querySelector('#password'); +const loginButton = form.querySelector('button[type="submit"]'); const logout = document.querySelector('#logout'); const message = document.querySelector('#message'); const nodes = document.querySelector('#nodes'); @@ -58,6 +59,7 @@ let nodeCapabilities = new Map(); let nodePlugins = new Map(); let inputGeneration = 0; let authenticationGeneration = 0; +let loginInFlight = false; function text(element, value) { element.textContent = value; @@ -297,6 +299,10 @@ async function loadNodes() { form.addEventListener('submit', async event => { event.preventDefault(); + if (loginInFlight) return; + loginInFlight = true; + loginButton.disabled = true; + logout.disabled = true; const loginGeneration = ++authenticationGeneration; const password = passwordInput.value; passwordInput.value = ''; @@ -320,6 +326,10 @@ form.addEventListener('submit', async event => { await loadNodes(); } catch (error) { text(message, error.message || 'Authentication failed.'); + } finally { + loginInFlight = false; + loginButton.disabled = false; + logout.disabled = false; } }); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index c748b1be..af42cb42 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -177,6 +177,46 @@ class ConfigurationOperationsTest { assertEquals(false, Files.exists(directory.resolve("configuration-audit.pending"))); } + @Test void auditPendingTransactionRestoresMissingRecordSeparator() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + Path active = directory.resolve("configuration-audit.jsonl"); + Path checkpoint = directory.resolve("configuration-audit.checkpoint"); + ObjectMapper json = new ObjectMapper(); + try (ConfigurationAuditLog log = new ConfigurationAuditLog(directory, clock)) { + log.append("FIRST", UUID.randomUUID(), "proxy-a", "OK"); + } + byte[] before = Files.readAllBytes(checkpoint); + try (ConfigurationAuditLog log = new ConfigurationAuditLog(directory, clock)) { + log.append("SECOND", UUID.randomUUID(), "proxy-a", "OK"); + } + byte[] after = Files.readAllBytes(checkpoint); + List lines = Files.readAllLines(active); + JsonNode pre = json.readTree(before); + JsonNode post = json.readTree(after); + byte[] firstLine = (lines.get(0) + System.lineSeparator()).getBytes(java.nio.charset.StandardCharsets.UTF_8); + Map pending = new LinkedHashMap<>(); + pending.put("line", lines.get(1) + System.lineSeparator()); + pending.put("rotate", false); + pending.put("preActiveHash", pre.path("activeHash").asText()); + pending.put("preActiveRecords", pre.path("activeRecords").asLong()); + pending.put("preRetainedHash", pre.path("retainedHash").asText()); + pending.put("preRetainedRecords", pre.path("retainedRecords").asLong()); + pending.put("preActiveBytes", firstLine.length); + pending.put("postActiveHash", post.path("activeHash").asText()); + pending.put("postActiveRecords", post.path("activeRecords").asLong()); + pending.put("postRetainedHash", post.path("retainedHash").asText()); + pending.put("postRetainedRecords", post.path("retainedRecords").asLong()); + Files.write(directory.resolve("configuration-audit.pending"), json.writeValueAsBytes(pending)); + Files.write(checkpoint, before); + Files.writeString(active, lines.get(0) + System.lineSeparator() + lines.get(1)); + + try (ConfigurationAuditLog log = new ConfigurationAuditLog(directory, clock)) { + log.append("THIRD", UUID.randomUUID(), "proxy-a", "OK"); + } + assertEquals(3, Files.readAllLines(active).size()); + assertEquals(false, Files.exists(directory.resolve("configuration-audit.pending"))); + } + @Test void auditLogRejectsASecondProcessWriter() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); try (ConfigurationAuditLog first = new ConfigurationAuditLog(directory, clock)) { diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 2564933c..6e0144d9 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -65,6 +65,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("nextPage.addEventListener")); assertTrue(script.body().contains("result.configuration?.content != null")); assertTrue(script.body().contains("authenticationGeneration")); + assertTrue(script.body().contains("if (loginInFlight) return")); assertTrue(web.body().contains("Easy vote reward")); assertTrue(script.body().contains("filteredSelection.size !== selectedNodes.size")); assertTrue(script.body().contains("previewGeneration === inputGeneration")); @@ -214,6 +215,25 @@ class ControlHttpServerTest { } } + @Test void queuedPasswordVerificationDoesNotBlockHealthOrAdminRequests() throws Exception { + credentials.setWebPassword("a-secure-web-password".toCharArray()); + java.util.List>> attempts = + java.util.stream.IntStream.range(0, 8).mapToObj(index -> + java.util.concurrent.CompletableFuture.supplyAsync(() -> { + try { + return send("POST", "/api/v1/auth/login", + "{\"password\":\"invalid-password-" + index + "\"}", null); + } catch (Exception e) { + throw new java.util.concurrent.CompletionException(e); + } + })).toList(); + assertEquals(200, get("/api/v1/health", null).statusCode()); + assertEquals(200, get("/api/v1/nodes", adminToken).statusCode()); + for (java.util.concurrent.CompletableFuture> attempt : attempts) { + assertEquals(401, attempt.join().statusCode()); + } + } + @Test void malformedEmptyNullDuplicateNestedOversizedAndLongPayloadsAreDeterministic() throws Exception { assertError(send("POST", "/api/v1/nodes/register", "{", nodeToken), 400, "MALFORMED_JSON"); assertError(send("POST", "/api/v1/nodes/register", "", nodeToken), 400, "MALFORMED_JSON"); From a6f87c8f62a0d8c5c4ae066cc06d8fe592d78881 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 25 Aug 2026 18:04:55 -0600 Subject: [PATCH 18/42] Bound topology pages and audit separators --- .../control/domain/ConfigurationAuditLog.java | 5 +++- .../control/http/ControlHttpServer.java | 28 +++++++++++++++++-- src/main/resources/web/app.js | 5 +++- .../domain/ConfigurationOperationsTest.java | 13 +++++++++ .../control/http/ControlHttpServerTest.java | 19 +++++++++++++ 5 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java index 6a995957..147288f1 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java @@ -248,10 +248,13 @@ private SegmentState validateOptional(Path selected, String label) throws IOExce } private SegmentState validateExisting(Path selected) throws IOException { - return validateLines(Files.readAllLines(selected, StandardCharsets.UTF_8)); + return validateBytes(Files.readAllBytes(selected)); } private SegmentState validateBytes(byte[] bytes) throws IOException { + if (bytes.length > 0 && bytes[bytes.length - 1] != '\n') { + throw new IOException("Configuration audit segment is missing its terminal separator"); + } try { String content = StandardCharsets.UTF_8.newDecoder() .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 44daa6ba..9d5ad464 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -67,6 +67,8 @@ public final class ControlHttpServer implements AutoCloseable { "/app.js", new WebResource("/web/app.js", "text/javascript; charset=utf-8"), "/app.css", new WebResource("/web/app.css", "text/css; charset=utf-8")); private static final int MAX_AUTH_FAILURES_PER_MINUTE = 100; + static final int MAX_BACKENDS_PER_NODE_PAGE = 4096; + private static final int MAX_BACKENDS_PER_NODE_SUMMARY = 256; private final HttpServer server; private final ObjectMapper json; @@ -255,8 +257,10 @@ private void route(HttpExchange exchange) throws IOException { Map query = query(uri.getRawQuery()); int offset = integer(query.getOrDefault("offset", "0"), "offset"); int limit = integer(query.getOrDefault("limit", "50"), "limit"); - List nodes = registry.list(offset, limit); - send(exchange, 200, Map.of("items", nodes, "offset", offset, "limit", limit)); + BackendPage page = boundedNodePage(registry.list(offset, limit)); + send(exchange, 200, Map.of("items", page.items(), "offset", offset, "limit", limit, + "backendItemsReturned", page.backendItemsReturned(), + "backendItemsTruncated", page.backendItemsTruncated())); return; } if (REGISTER.equals(path)) { @@ -379,6 +383,25 @@ private void handlePasswordLogin(HttpExchange exchange, PasswordRequest request) } } + static BackendPage boundedNodePage(List nodes) { + if (nodes.isEmpty()) return new BackendPage(List.of(), 0, false); + int perNode = Math.min(MAX_BACKENDS_PER_NODE_SUMMARY, + Math.max(1, MAX_BACKENDS_PER_NODE_PAGE / nodes.size())); + int returned = 0; + boolean truncated = false; + java.util.ArrayList items = new java.util.ArrayList<>(nodes.size()); + for (NodeStatus node : nodes) { + int count = Math.min(perNode, node.backends().size()); + returned += count; + truncated |= count < node.backends().size(); + items.add(new NodeStatus(node.nodeId(), node.sessionId(), node.displayName(), node.platform(), + node.pluginVersion(), node.protocolVersion(), node.advertisedCapabilities(), + node.acceptedCapabilities(), node.detectedPlugins(), node.backends().subList(0, count), + node.snapshotSequence(), node.lastSeen(), node.lastAuthenticatedUpdate(), node.online())); + } + return new BackendPage(List.copyOf(items), returned, truncated); + } + private T read(HttpExchange exchange, Class type) throws IOException { String contentType = exchange.getRequestHeaders().getFirst("Content-Type"); if (contentType == null || !contentType.toLowerCase(Locale.ROOT).startsWith("application/json")) { @@ -623,6 +646,7 @@ private static final class DeferredResponseException extends RuntimeException { private record WebResource(String classpath, String contentType) { } private record PasswordRequest(String password) { } + record BackendPage(List items, int backendItemsReturned, boolean backendItemsTruncated) { } static final class AuthFailureLimiter { private final java.util.function.LongSupplier nanoTime; diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index c227c539..f41f2dcf 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -283,7 +283,10 @@ async function loadNodes() { updateConfigurationButtons(); const first = body.items.length === 0 ? 0 : pageOffset + 1; const last = pageOffset + body.items.length; - text(message, body.items.length === 0 ? 'No proxies on this page.' : `Showing proxies ${first}–${last}.`); + const backendLimit = body.backendItemsTruncated + ? ` Backend summaries are limited to ${body.backendItemsReturned} entries on this page.` : ''; + text(message, body.items.length === 0 ? 'No proxies on this page.' + : `Showing proxies ${first}–${last}.${backendLimit}`); text(pageNumber, `Page ${Math.floor(pageOffset / PAGE_SIZE) + 1}`); previousPage.disabled = pageOffset === 0; nextPage.disabled = body.items.length < PAGE_SIZE; diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index af42cb42..a3af7b3b 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -135,6 +135,19 @@ class ConfigurationOperationsTest { assertThrows(java.io.IOException.class, () -> new ConfigurationAuditLog(directory, clock)); } + @Test void auditLogRejectsCommittedSegmentWithoutTerminalSeparator() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + Path active = directory.resolve("configuration-audit.jsonl"); + try (ConfigurationAuditLog log = new ConfigurationAuditLog(directory, clock)) { + log.append("FIRST", UUID.randomUUID(), "proxy-a", "OK"); + } + byte[] committed = Files.readAllBytes(active); + assertEquals((byte) '\n', committed[committed.length - 1]); + Files.write(active, java.util.Arrays.copyOf(committed, committed.length - 1)); + + assertThrows(java.io.IOException.class, () -> new ConfigurationAuditLog(directory, clock)); + } + @Test void auditPendingTransactionRecoversTornRecordBeforeCheckpointCrash() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); Path active = directory.resolve("configuration-audit.jsonl"); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 6e0144d9..949376ec 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -3,6 +3,8 @@ import com.bencodez.votingplugin.control.auth.CredentialStore; import com.bencodez.votingplugin.control.domain.InMemoryNodeRegistry; import com.bencodez.votingplugin.control.protocol.ControlIdentity; +import com.bencodez.votingplugin.control.protocol.BackendServerIdentity; +import com.bencodez.votingplugin.control.protocol.NodeStatus; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.net.InetSocketAddress; @@ -66,6 +68,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("result.configuration?.content != null")); assertTrue(script.body().contains("authenticationGeneration")); assertTrue(script.body().contains("if (loginInFlight) return")); + assertTrue(script.body().contains("backendItemsTruncated")); assertTrue(web.body().contains("Easy vote reward")); assertTrue(script.body().contains("filteredSelection.size !== selectedNodes.size")); assertTrue(script.body().contains("previewGeneration === inputGeneration")); @@ -83,6 +86,22 @@ class ControlHttpServerTest { "METHOD_NOT_ALLOWED"); } + @Test void nodePageBoundsBackendSummariesAcrossMaximumProxyPage() { + java.util.List backends = java.util.stream.IntStream.range(0, 300) + .mapToObj(index -> new BackendServerIdentity("backend-" + index, "Backend " + index, + true, true, index)).toList(); + java.util.List nodes = java.util.stream.IntStream.range(0, 100).mapToObj(index -> + new NodeStatus("proxy-" + index, UUID.randomUUID(), "Proxy " + index, "BUNGEECORD", "test", 1, + java.util.Set.of("presence.snapshot"), java.util.Set.of("presence.snapshot"), + java.util.Set.of(), backends, 1, java.time.Instant.EPOCH, java.time.Instant.EPOCH, true)).toList(); + + ControlHttpServer.BackendPage page = ControlHttpServer.boundedNodePage(nodes); + assertTrue(page.backendItemsTruncated()); + assertTrue(page.backendItemsReturned() <= ControlHttpServer.MAX_BACKENDS_PER_NODE_PAGE); + assertEquals(100, page.items().size()); + assertTrue(page.items().stream().allMatch(node -> node.backends().size() == 40)); + } + @Test void validEnrollmentAuthenticatesRegistrationHeartbeatPresenceAndAdminListing() throws Exception { HttpResponse registered = send("POST", "/api/v1/nodes/register", registration(), nodeToken); assertEquals(201, registered.statusCode()); From 2a3f879bba3b6f4fb292aa1f49547e10ba3057e0 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 25 Aug 2026 18:11:38 -0600 Subject: [PATCH 19/42] Recover pending audit before new appends --- .../control/domain/ConfigurationAuditLog.java | 3 ++ .../domain/ConfigurationOperationsTest.java | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java index 147288f1..86228920 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java @@ -79,6 +79,9 @@ public ConfigurationAuditLog(Path dataDirectory, Clock clock) throws IOException public synchronized void append(String action, UUID operationId, String nodeId, String outcome) { try { + // A prior append may have changed a segment before checkpoint publication failed. + // Never replace its only durable recovery description with a new transaction. + if (Files.exists(pendingFile, LinkOption.NOFOLLOW_LINKS)) recoverPending(); boolean rotate = Files.exists(file) && Files.size(file) >= maxBytes; String recordPrevious = rotate ? "GENESIS" : previousHash; Map core = new LinkedHashMap<>(); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index a3af7b3b..ebe1584d 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -230,6 +230,49 @@ class ConfigurationOperationsTest { assertEquals(false, Files.exists(directory.resolve("configuration-audit.pending"))); } + @Test void liveAuditLogRecoversExistingPendingTransactionBeforeNextAppend() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + Path active = directory.resolve("configuration-audit.jsonl"); + Path checkpoint = directory.resolve("configuration-audit.checkpoint"); + Path pendingPath = directory.resolve("configuration-audit.pending"); + ObjectMapper json = new ObjectMapper(); + try (ConfigurationAuditLog log = new ConfigurationAuditLog(directory, clock)) { + log.append("FIRST", UUID.randomUUID(), "proxy-a", "OK"); + } + byte[] before = Files.readAllBytes(checkpoint); + try (ConfigurationAuditLog log = new ConfigurationAuditLog(directory, clock)) { + log.append("SECOND", UUID.randomUUID(), "proxy-a", "OK"); + } + byte[] after = Files.readAllBytes(checkpoint); + List lines = Files.readAllLines(active); + JsonNode pre = json.readTree(before); + JsonNode post = json.readTree(after); + byte[] firstLine = (lines.get(0) + System.lineSeparator()).getBytes(java.nio.charset.StandardCharsets.UTF_8); + Map pending = new LinkedHashMap<>(); + pending.put("line", lines.get(1) + System.lineSeparator()); + pending.put("rotate", false); + pending.put("preActiveHash", pre.path("activeHash").asText()); + pending.put("preActiveRecords", pre.path("activeRecords").asLong()); + pending.put("preRetainedHash", pre.path("retainedHash").asText()); + pending.put("preRetainedRecords", pre.path("retainedRecords").asLong()); + pending.put("preActiveBytes", firstLine.length); + pending.put("postActiveHash", post.path("activeHash").asText()); + pending.put("postActiveRecords", post.path("activeRecords").asLong()); + pending.put("postRetainedHash", post.path("retainedHash").asText()); + pending.put("postRetainedRecords", post.path("retainedRecords").asLong()); + Files.write(active, firstLine); + Files.write(checkpoint, before); + + try (ConfigurationAuditLog live = new ConfigurationAuditLog(directory, clock)) { + Files.writeString(active, lines.get(0) + System.lineSeparator() + lines.get(1) + + System.lineSeparator()); + Files.write(pendingPath, json.writeValueAsBytes(pending)); + live.append("THIRD", UUID.randomUUID(), "proxy-a", "OK"); + } + assertEquals(3, Files.readAllLines(active).size()); + assertEquals(false, Files.exists(pendingPath)); + } + @Test void auditLogRejectsASecondProcessWriter() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); try (ConfigurationAuditLog first = new ConfigurationAuditLog(directory, clock)) { From 50e9505c3fa24acba091b871cb9945c657961e35 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 25 Aug 2026 18:25:00 -0600 Subject: [PATCH 20/42] Bound result details and password admission --- .../domain/ConfigurationOperations.java | 67 +++++++++++++++++-- .../control/http/ControlHttpServer.java | 46 ++++++++++++- .../domain/ConfigurationOperationsTest.java | 32 +++++++++ .../control/http/ControlHttpServerTest.java | 11 +++ 4 files changed, 148 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 6047c264..67f86091 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -5,6 +5,7 @@ import com.bencodez.votingplugin.control.protocol.NodeStatus; import com.bencodez.votingplugin.control.protocol.ProxyRoutingConfiguration; import com.bencodez.votingplugin.control.protocol.ManagedConfiguration; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.SecureRandom; import java.time.Clock; @@ -28,6 +29,7 @@ public final class ConfigurationOperations implements AutoCloseable { public static final String QUICK_SETUP_CAPABILITY = "config.quick-setup.v1"; private static final int MAX_OPERATIONS = 1000; private static final int MAX_FILE_OPERATIONS = 16; + static final int MAX_RETAINED_CHANGE_BYTES = 256 * 1024; private static final Duration LEASE = Duration.ofMinutes(2); private static final Duration ACTIVE_RETENTION = Duration.ofMinutes(15); private static final Duration RETENTION = Duration.ofHours(24); @@ -37,6 +39,7 @@ public final class ConfigurationOperations implements AutoCloseable { private final Clock clock; private final SecureRandom random = new SecureRandom(); private final LinkedHashMap operations = new LinkedHashMap<>(); + private long retainedChangeBytes; public ConfigurationOperations(NodeRegistry registry, ConfigurationAuditLog audit, Clock clock) { this.registry = Objects.requireNonNull(registry); @@ -196,6 +199,7 @@ private void ensureFileCapacity(ManagedConfiguration config, UUID protectedOpera StoredOperation candidate = iterator.next().getValue(); if (candidate.fileOperation() && candidate.complete() && !candidate.id.equals(protectedOperation)) { audit.append("OPERATION_EVICTED", candidate.id, null, "FILE_RETENTION_LIMIT"); + releaseResultDetails(candidate); iterator.remove(); retained--; } @@ -205,15 +209,63 @@ private void ensureFileCapacity(ManagedConfiguration config, UUID protectedOpera } } - private static ConfigurationTaskResult boundedResult(StoredOperation operation, ConfigurationTaskResult result) { + private ConfigurationTaskResult boundedResult(StoredOperation operation, ConfigurationTaskResult result) { ManagedConfiguration configuration = result.configuration(); - if (configuration == null || !ManagedConfiguration.FILE.equals(configuration.domain())) return result; - boolean keepContent = "READ".equals(operation.type) && operation.results.values().stream() - .map(ConfigurationTaskResult::configuration).filter(Objects::nonNull) - .noneMatch(value -> ManagedConfiguration.FILE.equals(value.domain()) && value.content() != null); - if (keepContent) return result; + if (configuration != null && ManagedConfiguration.FILE.equals(configuration.domain())) { + boolean keepContent = "READ".equals(operation.type) && operation.results.values().stream() + .map(ConfigurationTaskResult::configuration).filter(Objects::nonNull) + .noneMatch(value -> ManagedConfiguration.FILE.equals(value.domain()) && value.content() != null); + if (!keepContent) configuration = configuration.publicView(); + } + List changes = retainChanges(result.changes()); return new ConfigurationTaskResult(result.sessionId(), result.success(), result.code(), result.message(), - result.revision(), configuration.publicView(), result.changes(), result.reloaded(), result.rolledBack()); + result.revision(), configuration, changes, result.reloaded(), result.rolledBack()); + } + + private List retainChanges(List changes) { + long remaining = Math.max(0, MAX_RETAINED_CHANGE_BYTES - retainedChangeBytes); + if (remaining == 0 || changes.isEmpty()) return List.of(); + List retained = new ArrayList<>(); + long added = 0; + for (String change : changes) { + byte[] bytes = change.getBytes(StandardCharsets.UTF_8); + if (bytes.length <= remaining) { + retained.add(change); + remaining -= bytes.length; + added += bytes.length; + continue; + } + String truncated = truncateUtf8(change, (int) remaining); + if (!truncated.isEmpty()) { + retained.add(truncated); + added += truncated.getBytes(StandardCharsets.UTF_8).length; + } + break; + } + retainedChangeBytes += added; + return List.copyOf(retained); + } + + private static String truncateUtf8(String value, int maximumBytes) { + int end = 0; + int bytes = 0; + while (end < value.length()) { + int codePoint = value.codePointAt(end); + int encoded = codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + if (bytes + encoded > maximumBytes) break; + bytes += encoded; + end += Character.charCount(codePoint); + } + return value.substring(0, end); + } + + private void releaseResultDetails(StoredOperation operation) { + for (ConfigurationTaskResult result : operation.results.values()) { + for (String change : result.changes()) { + retainedChangeBytes -= change.getBytes(StandardCharsets.UTF_8).length; + } + } + if (retainedChangeBytes < 0) retainedChangeBytes = 0; } private List validateTargets(List nodeIds, String capability) { @@ -252,6 +304,7 @@ private void prune() { if (operation.createdAt.isBefore(operation.complete() ? completedCutoff : activeCutoff)) { audit.append("OPERATION_EXPIRED", operation.id, null, operation.complete() ? "RETAINED" : "ABANDONED"); + releaseResultDetails(operation); iterator.remove(); } } diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 9d5ad464..a17fd9f6 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -67,6 +67,7 @@ public final class ControlHttpServer implements AutoCloseable { "/app.js", new WebResource("/web/app.js", "text/javascript; charset=utf-8"), "/app.css", new WebResource("/web/app.css", "text/css; charset=utf-8")); private static final int MAX_AUTH_FAILURES_PER_MINUTE = 100; + private static final int MAX_PASSWORD_ATTEMPTS_PER_CLIENT = 8; static final int MAX_BACKENDS_PER_NODE_PAGE = 4096; private static final int MAX_BACKENDS_PER_NODE_SUMMARY = 256; @@ -78,6 +79,7 @@ public final class ControlHttpServer implements AutoCloseable { private final ConfigurationOperations configurationOperations; private final ThreadPoolExecutor executor; private final ThreadPoolExecutor passwordExecutor; + private final PasswordAdmission passwordAdmission = new PasswordAdmission(MAX_PASSWORD_ATTEMPTS_PER_CLIENT); private final AuthFailureLimiter authLimiter; private final WebSessionStore webSessions; private final boolean secureCookies; @@ -225,9 +227,20 @@ private void route(HttpExchange exchange) throws IOException { requireMethod(exchange, "POST"); PasswordRequest request = read(exchange, PasswordRequest.class); requireRequest(request); + String client = passwordClient(exchange); + if (!passwordAdmission.acquire(client)) { + throw new AuthenticationException(true); + } try { - passwordExecutor.execute(() -> handlePasswordLogin(exchange, request)); + passwordExecutor.execute(() -> { + try { + handlePasswordLogin(exchange, request); + } finally { + passwordAdmission.release(client); + } + }); } catch (RejectedExecutionException e) { + passwordAdmission.release(client); throw new AuthenticationException(true); } throw new DeferredResponseException(); @@ -499,6 +512,12 @@ private static String bearer(HttpExchange exchange) { ? value.substring(7) : null; } + private static String passwordClient(HttpExchange exchange) { + InetSocketAddress remote = exchange.getRemoteAddress(); + if (remote == null) return "unknown"; + return remote.getAddress() == null ? remote.getHostString() : remote.getAddress().getHostAddress(); + } + private static String cookie(HttpExchange exchange, String name) { String found = null; List headers = exchange.getRequestHeaders().get("Cookie"); @@ -682,4 +701,29 @@ private void resetExpired() { } } } + + static final class PasswordAdmission { + private final int maximumPerClient; + private final Map inFlight = new HashMap<>(); + + PasswordAdmission(int maximumPerClient) { + this.maximumPerClient = maximumPerClient; + } + + synchronized boolean acquire(String client) { + int current = inFlight.getOrDefault(client, 0); + if (current >= maximumPerClient) return false; + inFlight.put(client, current + 1); + return true; + } + + synchronized void release(String client) { + Integer current = inFlight.get(client); + if (current == null || current <= 1) { + inFlight.remove(client); + } else { + inFlight.put(client, current - 1); + } + } + } } diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index ebe1584d..690df80b 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.bencodez.votingplugin.control.protocol.ConfigurationTask; import com.bencodez.votingplugin.control.protocol.ConfigurationTaskResult; @@ -374,6 +375,37 @@ class ConfigurationOperationsTest { () -> operations.createRead(List.of("backend-a"), selector)).code()); } + @Test void aggregateRetainedChangeDetailsHaveAGlobalByteBudget() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + List nodes = new java.util.ArrayList<>(); + Map sessions = new LinkedHashMap<>(); + for (int i = 0; i < 100; i++) { + String node = "proxy-" + i; + UUID session = UUID.randomUUID(); + nodes.add(node); + sessions.put(node, session); + registry.register(new NodeRegistration(node, session, node, "VELOCITY", "test", 1, + Set.of(ConfigurationOperations.CAPABILITY), Set.of())); + } + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + ConfigurationOperations.OperationView read = operations.createRead(nodes); + ProxyRoutingConfiguration configuration = new ProxyRoutingConfiguration(false, List.of()); + List maximumChanges = java.util.stream.IntStream.range(0, 20) + .mapToObj(ignored -> "x".repeat(500)).toList(); + for (String node : nodes) { + operations.claim(node, sessions.get(node)); + read = operations.complete(read.operationId(), node, new ConfigurationTaskResult(sessions.get(node), + true, "OK", "read", "a".repeat(64), configuration, maximumChanges, false, false)); + } + + long retainedBytes = read.results().values().stream().flatMap(result -> result.changes().stream()) + .mapToLong(value -> value.getBytes(java.nio.charset.StandardCharsets.UTF_8).length).sum(); + assertTrue(retainedBytes <= ConfigurationOperations.MAX_RETAINED_CHANGE_BYTES); + assertTrue(retainedBytes < 100L * 20 * 500); + } + private static final class MutableClock extends Clock { private Instant instant; private MutableClock(Instant instant) { this.instant = instant; } diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 949376ec..6dad97a0 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -234,6 +234,17 @@ class ControlHttpServerTest { } } + @Test void passwordAdmissionPreventsOneClientFromOccupyingAllVerificationCapacity() { + ControlHttpServer.PasswordAdmission admission = new ControlHttpServer.PasswordAdmission(2); + assertTrue(admission.acquire("attacker")); + assertTrue(admission.acquire("attacker")); + assertFalse(admission.acquire("attacker")); + assertTrue(admission.acquire("owner")); + admission.release("attacker"); + assertTrue(admission.acquire("attacker")); + admission.release("owner"); + } + @Test void queuedPasswordVerificationDoesNotBlockHealthOrAdminRequests() throws Exception { credentials.setWebPassword("a-secure-web-password".toCharArray()); java.util.List>> attempts = From 9811b175bfa5c10384760e052eabc5cc9ee53df6 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 25 Aug 2026 18:36:53 -0600 Subject: [PATCH 21/42] Invalidate previews and trust proxy identity --- README.md | 3 +- .../control/ControlApplication.java | 23 +++++++-- .../domain/ConfigurationOperations.java | 3 ++ .../control/http/ControlHttpServer.java | 50 ++++++++++++++++--- src/main/resources/web/app.js | 12 +++++ .../control/ControlApplicationTest.java | 7 ++- .../domain/ConfigurationOperationsTest.java | 1 + .../control/http/ControlHttpServerTest.java | 10 ++++ 8 files changed, 98 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3cbaed43..5a471af1 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,10 @@ report `development` rather than maintaining a second version literal. | `CONTROL_OFFLINE_TIMEOUT_SECONDS` | `90` | `1`–`3600` | | `CONTROL_REQUEST_TIMEOUT_SECONDS` | `10` | `1`–`60`; bounds stalled JDK HTTP exchanges | | `CONTROL_SECURE_COOKIE` | `false` | Set `true` only when browsers reach Control through a trusted HTTPS reverse proxy | +| `CONTROL_TRUSTED_PROXY_ADDRESSES` | empty | Comma-separated IP literals for reverse proxies allowed to supply `X-Forwarded-For` login admission identity | The server also uses a bounded HTTP executor (8 active requests and a 32-request queue), a 768 KiB request limit, bounded -JSON depth/string/number sizes, a two-slot password-verification semaphore, and a bounded invalid-authentication failure limit. Valid enrolled/admin credentials remain +JSON depth/string/number sizes, a two-worker password-verification executor with per-client admission, and a bounded invalid-authentication failure limit. Configure the exact reverse-proxy IP addresses when HTTPS terminates upstream; forwarding headers from every other peer are ignored. Valid enrolled/admin credentials remain usable even while invalid traffic is throttled. Shutdown stops the server and its daemon request workers without waiting indefinitely. diff --git a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java index 2c1ddca1..23aeb7c4 100644 --- a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java +++ b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java @@ -18,7 +18,9 @@ import java.nio.file.StandardOpenOption; import java.time.Clock; import java.time.Duration; +import java.util.LinkedHashSet; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CountDownLatch; @@ -133,7 +135,7 @@ static void runServer(Map environment) throws Exception { ConfigurationOperations operations = new ConfigurationOperations(registry, new ConfigurationAuditLog(configuration.dataDirectory(), clock), clock); ControlHttpServer server = new ControlHttpServer(configuration.address(), registry, identity, credentials, - operations, configuration.secureCookies()); + operations, configuration.secureCookies(), configuration.trustedProxyAddresses()); CountDownLatch shutdown = new CountDownLatch(1); Runtime.getRuntime().addShutdownHook(new Thread(() -> { server.close(); @@ -186,7 +188,7 @@ private static Path requirePath(String value) { } record Configuration(InetSocketAddress address, Path dataDirectory, Duration offlineTimeout, - int requestTimeoutSeconds, boolean secureCookies) { + int requestTimeoutSeconds, boolean secureCookies, Set trustedProxyAddresses) { static Configuration from(Map environment) throws IOException { String host = environment.getOrDefault("CONTROL_HOST", "127.0.0.1").trim(); if (host.isEmpty()) { @@ -200,9 +202,24 @@ static Configuration from(Map environment) throws IOException { "request timeout", 1, 60); boolean secureCookies = strictBoolean(environment.getOrDefault("CONTROL_SECURE_COOKIE", "false"), "secure cookie"); + Set trustedProxies = trustedProxyAddresses( + environment.getOrDefault("CONTROL_TRUSTED_PROXY_ADDRESSES", "")); return new Configuration(new InetSocketAddress(resolved, port), requirePath(environment.getOrDefault("CONTROL_DATA_DIR", "data")), - Duration.ofSeconds(offlineSeconds), requestSeconds, secureCookies); + Duration.ofSeconds(offlineSeconds), requestSeconds, secureCookies, trustedProxies); + } + + private static Set trustedProxyAddresses(String value) throws IOException { + if (value == null || value.isBlank()) return Set.of(); + Set result = new LinkedHashSet<>(); + for (String item : value.split(",", -1)) { + String candidate = item.trim(); + boolean ipv4 = candidate.matches("[0-9]{1,3}(\\.[0-9]{1,3}){3}"); + boolean ipv6 = candidate.indexOf(':') >= 0 && candidate.matches("[0-9A-Fa-f:.]+"); + if (!ipv4 && !ipv6) throw new IllegalArgumentException("Invalid trusted proxy address"); + result.add(InetAddress.getByName(candidate).getHostAddress()); + } + return Set.copyOf(result); } private static int boundedInteger(String value, String name, int minimum, int maximum) { diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 67f86091..97709818 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -216,6 +216,9 @@ private ConfigurationTaskResult boundedResult(StoredOperation operation, Configu .map(ConfigurationTaskResult::configuration).filter(Objects::nonNull) .noneMatch(value -> ManagedConfiguration.FILE.equals(value.domain()) && value.content() != null); if (!keepContent) configuration = configuration.publicView(); + } else if (configuration != null && operation.results.values().stream() + .anyMatch(existing -> existing.configuration() != null)) { + configuration = null; } List changes = retainChanges(result.changes()); return new ConfigurationTaskResult(result.sessionId(), result.success(), result.code(), result.message(), diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index a17fd9f6..9469a859 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -27,6 +27,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.URI; import java.net.URLDecoder; @@ -42,6 +43,7 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.RejectedExecutionException; @@ -83,37 +85,47 @@ public final class ControlHttpServer implements AutoCloseable { private final AuthFailureLimiter authLimiter; private final WebSessionStore webSessions; private final boolean secureCookies; + private final Set trustedProxyAddresses; public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials) throws IOException { this(address, registry, identity, credentials, new ConfigurationOperations(registry, new com.bencodez.votingplugin.control.domain.ConfigurationAuditLog( java.nio.file.Files.createTempDirectory("votingplugin-control-test-audit"), Clock.systemUTC()), - Clock.systemUTC()), Clock.systemUTC(), System::nanoTime, false); + Clock.systemUTC()), Clock.systemUTC(), System::nanoTime, false, Set.of()); } public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials, ConfigurationOperations configurationOperations) throws IOException { this(address, registry, identity, credentials, configurationOperations, Clock.systemUTC(), System::nanoTime, - false); + false, Set.of()); } public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials, ConfigurationOperations configurationOperations, boolean secureCookies) throws IOException { this(address, registry, identity, credentials, configurationOperations, Clock.systemUTC(), System::nanoTime, - secureCookies); + secureCookies, Set.of()); + } + + public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, + CredentialStore credentials, ConfigurationOperations configurationOperations, + boolean secureCookies, Set trustedProxyAddresses) throws IOException { + this(address, registry, identity, credentials, configurationOperations, Clock.systemUTC(), System::nanoTime, + secureCookies, trustedProxyAddresses); } ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials, ConfigurationOperations configurationOperations, Clock clock, - java.util.function.LongSupplier nanoTime, boolean secureCookies) throws IOException { + java.util.function.LongSupplier nanoTime, boolean secureCookies, + Set trustedProxyAddresses) throws IOException { this.registry = Objects.requireNonNull(registry, "registry"); this.identity = Objects.requireNonNull(identity, "identity"); this.credentials = Objects.requireNonNull(credentials, "credentials"); this.configurationOperations = Objects.requireNonNull(configurationOperations, "configurationOperations"); this.secureCookies = secureCookies; + this.trustedProxyAddresses = Set.copyOf(Objects.requireNonNull(trustedProxyAddresses, "trustedProxyAddresses")); Objects.requireNonNull(clock, "clock"); json = new ObjectMapper(); json.findAndRegisterModules(); @@ -512,10 +524,36 @@ private static String bearer(HttpExchange exchange) { ? value.substring(7) : null; } - private static String passwordClient(HttpExchange exchange) { + private String passwordClient(HttpExchange exchange) { InetSocketAddress remote = exchange.getRemoteAddress(); if (remote == null) return "unknown"; - return remote.getAddress() == null ? remote.getHostString() : remote.getAddress().getHostAddress(); + String peer = remote.getAddress() == null ? remote.getHostString() : remote.getAddress().getHostAddress(); + return forwardedPasswordClient(peer, singleHeader(exchange, "X-Forwarded-For"), trustedProxyAddresses); + } + + static String forwardedPasswordClient(String peer, String forwardedFor, Set trustedProxies) { + if (!trustedProxies.contains(peer) || forwardedFor == null || forwardedFor.isBlank()) return peer; + String selected = peer; + String[] chain = forwardedFor.split(",", -1); + for (int index = chain.length - 1; index >= 0; index--) { + String address = canonicalIpLiteral(chain[index]); + if (address == null) return peer; + selected = address; + if (!trustedProxies.contains(address)) return address; + } + return selected; + } + + private static String canonicalIpLiteral(String value) { + String candidate = value == null ? "" : value.trim(); + boolean ipv4 = candidate.matches("[0-9]{1,3}(\\.[0-9]{1,3}){3}"); + boolean ipv6 = candidate.indexOf(':') >= 0 && candidate.matches("[0-9A-Fa-f:.]+"); + if (!ipv4 && !ipv6) return null; + try { + return InetAddress.getByName(candidate).getHostAddress(); + } catch (IOException e) { + return null; + } } private static String cookie(HttpExchange exchange, String name) { diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index f41f2dcf..87544925 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -252,9 +252,21 @@ async function loadNodes() { } else { body.items.forEach(node => nodes.append(nodeCard(node))); } + const previousCapabilities = nodeCapabilities; nodeCapabilities = new Map(body.items.map(node => [node.nodeId, node.online ? node.acceptedCapabilities : []])); nodePlugins = new Map(body.items.map(node => [node.nodeId, node.online && Array.isArray(node.detectedPlugins) ? node.detectedPlugins : []])); + const selectedCapabilitiesChanged = [...selectedNodes].some(node => + ['config.proxy-routing.v1', 'config.files.v1', 'config.quick-setup.v1'].some(capability => + Boolean(previousCapabilities.get(node)?.includes(capability)) !== + Boolean(nodeCapabilities.get(node)?.includes(capability)))); + if (selectedCapabilitiesChanged) { + approvedPreview = null; + approvedFilePreview = null; + approvedQuickPreview = null; + inputGeneration++; + text(operationStatus, 'A selected node changed capabilities during refresh. Preview again before apply.'); + } const invalidRoutingApproval = approvedPreview && !approvedPreview.nodeIds.every(node => nodeCapabilities.get(node)?.includes('config.proxy-routing.v1')); const invalidFileApproval = approvedFilePreview && !approvedFilePreview.nodeIds.every(node => diff --git a/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java index 92fa86b2..dd50b213 100644 --- a/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java @@ -80,12 +80,17 @@ class ControlApplicationTest { assertTrue(java.util.Arrays.equals(new char[supplied.length], supplied)); } - @Test void startupConfigurationValidatesAllBounds() { + @Test void startupConfigurationValidatesAllBounds() throws Exception { assertThrows(IllegalArgumentException.class, () -> ControlApplication.Configuration.from(java.util.Map.of("CONTROL_PORT", "70000"))); assertThrows(IllegalArgumentException.class, () -> ControlApplication.Configuration.from(java.util.Map.of("CONTROL_OFFLINE_TIMEOUT_SECONDS", "0"))); assertThrows(IllegalArgumentException.class, () -> ControlApplication.Configuration.from(java.util.Map.of("CONTROL_REQUEST_TIMEOUT_SECONDS", "61"))); + assertThrows(IllegalArgumentException.class, + () -> ControlApplication.Configuration.from(java.util.Map.of( + "CONTROL_TRUSTED_PROXY_ADDRESSES", "proxy.local"))); + assertEquals(Set.of("127.0.0.1"), ControlApplication.Configuration.from(java.util.Map.of( + "CONTROL_TRUSTED_PROXY_ADDRESSES", "127.0.0.1")).trustedProxyAddresses()); } } diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 690df80b..0ad9b520 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -404,6 +404,7 @@ class ConfigurationOperationsTest { .mapToLong(value -> value.getBytes(java.nio.charset.StandardCharsets.UTF_8).length).sum(); assertTrue(retainedBytes <= ConfigurationOperations.MAX_RETAINED_CHANGE_BYTES); assertTrue(retainedBytes < 100L * 20 * 500); + assertEquals(1, read.results().values().stream().filter(result -> result.configuration() != null).count()); } private static final class MutableClock extends Clock { diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 6dad97a0..27d55585 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -73,6 +73,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("filteredSelection.size !== selectedNodes.size")); assertTrue(script.body().contains("previewGeneration === inputGeneration")); assertTrue(script.body().contains("approvedPreview.nodeIds.every")); + assertTrue(script.body().contains("selectedCapabilitiesChanged")); assertEquals(200, get("/app.css", null).statusCode()); assertError(send("POST", "/", null, null), 405, "METHOD_NOT_ALLOWED"); assertEquals(200, get("/api/v1/health", null).statusCode()); @@ -245,6 +246,15 @@ class ControlHttpServerTest { admission.release("owner"); } + @Test void trustedProxyUsesForwardedClientWhileDirectPeersCannotSpoofIt() { + assertEquals("203.0.113.9", ControlHttpServer.forwardedPasswordClient("127.0.0.1", "203.0.113.9", + java.util.Set.of("127.0.0.1"))); + assertEquals("198.51.100.4", ControlHttpServer.forwardedPasswordClient("198.51.100.4", "203.0.113.9", + java.util.Set.of("127.0.0.1"))); + assertEquals("127.0.0.1", ControlHttpServer.forwardedPasswordClient("127.0.0.1", "not-an-address", + java.util.Set.of("127.0.0.1"))); + } + @Test void queuedPasswordVerificationDoesNotBlockHealthOrAdminRequests() throws Exception { credentials.setWebPassword("a-secure-web-password".toCharArray()); java.util.List>> attempts = From a9c2d67ded32c5eba05a8b95745edb82ab5c5c58 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 25 Aug 2026 18:45:31 -0600 Subject: [PATCH 22/42] Clear stale WebUI configuration state --- src/main/resources/web/app.js | 49 +++++++++++-------- .../control/http/ControlHttpServerTest.java | 2 + 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 87544925..e60fa0b8 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -194,10 +194,32 @@ async function authorized(path, options = {}) { headers: {...(options.headers || {}), ...(method === 'GET' ? {} : {'X-CSRF-Token': csrfToken})} }); const body = response.status === 204 ? null : await response.json(); + if (response.status === 401) discardAuthenticationState('Session expired. Sign in again.'); if (!response.ok) throw new Error(body?.error?.message || `Control request failed (${response.status}).`); return body; } +function discardAuthenticationState(reason) { + authenticationGeneration++; + authenticated = false; + csrfToken = ''; + approvedPreview = null; + approvedFilePreview = null; + approvedQuickPreview = null; + inputGeneration++; + logout.hidden = true; + selectedNodes.clear(); + nodeCapabilities.clear(); + nodePlugins.clear(); + configurationContent.value = ''; + blockedServers.value = ''; + nodes.replaceChildren(); + nodes.classList.add('empty'); + text(nodes, 'Authenticate to view the network.'); + text(message, reason); + updateConfigurationButtons(); +} + function proposal() { return { sendVotesToAllServers: sendAll.checked, @@ -335,6 +357,7 @@ form.addEventListener('submit', async event => { approvedFilePreview = null; approvedQuickPreview = null; selectedNodes.clear(); + configurationContent.value = ''; inputGeneration++; logout.hidden = false; pageOffset = 0; @@ -349,23 +372,8 @@ form.addEventListener('submit', async event => { }); logout.addEventListener('click', async () => { - authenticationGeneration++; try { await authorized('/api/v1/auth/logout', {method: 'POST'}); } catch (_) { /* Expired is logged out too. */ } - authenticated = false; - csrfToken = ''; - approvedPreview = null; - approvedFilePreview = null; - approvedQuickPreview = null; - inputGeneration++; - logout.hidden = true; - selectedNodes.clear(); - nodeCapabilities.clear(); - nodePlugins.clear(); - nodes.replaceChildren(); - nodes.classList.add('empty'); - text(nodes, 'Authenticate to view the network.'); - text(message, 'Signed out.'); - updateConfigurationButtons(); + discardAuthenticationState('Signed out.'); }); async function restoreSession() { @@ -386,11 +394,10 @@ readConfiguration.addEventListener('click', async () => { approvedPreview = null; try { const operation = await startConfigurationOperation('/api/v1/configuration/read', {nodeIds: targets('config.proxy-routing.v1')}); - const successful = Object.values(operation.results).filter(result => result.success); - if (successful.length) { - const first = successful[0].configuration; - sendAll.checked = first.sendVotesToAllServers; - blockedServers.value = first.blockedServers.join('\n'); + const retained = Object.values(operation.results).find(result => result.success && result.configuration); + if (retained) { + sendAll.checked = retained.configuration.sendVotesToAllServers; + blockedServers.value = retained.configuration.blockedServers.join('\n'); } } catch (error) { text(operationStatus, error.message); } }); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 27d55585..0bc3c274 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -74,6 +74,8 @@ class ControlHttpServerTest { assertTrue(script.body().contains("previewGeneration === inputGeneration")); assertTrue(script.body().contains("approvedPreview.nodeIds.every")); assertTrue(script.body().contains("selectedCapabilitiesChanged")); + assertTrue(script.body().contains("discardAuthenticationState")); + assertTrue(script.body().contains("result.success && result.configuration")); assertEquals(200, get("/app.css", null).statusCode()); assertError(send("POST", "/", null, null), 405, "METHOD_NOT_ALLOWED"); assertEquals(200, get("/api/v1/health", null).statusCode()); From f7dc72793d23aa4cb0c2ecf58b1499600e362b11 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 25 Aug 2026 18:55:24 -0600 Subject: [PATCH 23/42] Close control state and durability races --- README.md | 4 +++- .../control/domain/ConfigurationAuditLog.java | 19 +++++++++++++-- .../control/domain/InMemoryNodeRegistry.java | 20 ++++++++++++++-- .../control/http/ControlHttpServer.java | 3 ++- src/main/resources/web/app.js | 14 ++++++++--- .../domain/InMemoryNodeRegistryTest.java | 23 ++++++++++++++++++- 6 files changed, 73 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5a471af1..b0e802f9 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,8 @@ and all unknown endpoints return a structured 404. Registration includes a stable node ID and a random process session ID. A new session replaces the old registration and clears its old topology. Presence snapshots are full replacements, contain at most 4096 unique backend IDs, and use a -monotonic sequence within the registered session. Replayed or out-of-order sequence numbers are idempotently ignored. +monotonic sequence within the registered session. Control retains at most 65536 backend entries across the whole registry; +an over-capacity replacement is rejected atomically with `409 REGISTRY_LIMIT`. Replayed or out-of-order sequence numbers are idempotently ignored. Control records its own observation time; it does not trust remote wall-clock time for online/offline decisions. Configuration is split into independently negotiated capabilities. `config.proxy-routing.v1` exposes typed proxy routing. @@ -169,6 +170,7 @@ They are not misleadingly used by these simple HTTP resource endpoints. - `401 UNAUTHORIZED`: the credential is missing, invalid, revoked, rotated, or enrolled for another node ID. - `403 CSRF_REQUIRED`: refresh/sign in again and retry the WebUI write from the same session. - `409 SESSION_MISMATCH`: re-register; Control has a newer process session for that stable node. +- `409 REGISTRY_LIMIT`: reduce a node's backend snapshot before retrying; the previous snapshot remains intact. - `409 UNSUPPORTED_PROTOCOL` / `INCOMPATIBLE_CAPABILITIES`: upgrade the older side before retrying. - Node stays offline: verify its connector is enabled, the node ID matches enrollment, and heartbeat timeouts permit the configured interval. diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java index 86228920..125fcf52 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java @@ -168,7 +168,7 @@ && matches(retained, pending.postRetainedHash(), pending.postRetainedRecords())) previousHash = pending.postActiveHash(); activeRecords = pending.postActiveRecords(); writeCheckpoint(); - Files.delete(pendingFile); + deleteDurably(pendingFile); } private SegmentState repairTornActive(PendingAppend pending, SegmentState retained, @@ -193,7 +193,7 @@ private SegmentState repairTornActive(PendingAppend pending, SegmentState retain if (actual[index] != intended[index - base]) throw invalidActive; } if (base == 0) { - Files.delete(file); + deleteDurably(file); } else { try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) { channel.truncate(base); @@ -215,9 +215,11 @@ private static boolean matches(SegmentState state, String hash, long records) { } private void appendDurably(String line) throws IOException { + boolean existed = Files.exists(file, LinkOption.NOFOLLOW_LINKS); Files.writeString(file, line, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE); try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) { channel.force(true); } + if (!existed) forceDirectory(file.getParent()); } private void writePending(PendingAppend pending) throws IOException { @@ -353,6 +355,7 @@ private static void writeAtomically(Path target, byte[] bytes) throws IOExceptio } catch (java.nio.file.AtomicMoveNotSupportedException e) { Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); } + forceDirectory(target.getParent()); } finally { Files.deleteIfExists(temporary); } @@ -364,6 +367,18 @@ private static void moveReplacing(Path source, Path target) throws IOException { } catch (java.nio.file.AtomicMoveNotSupportedException e) { Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); } + forceDirectory(target.getParent()); + if (!source.getParent().equals(target.getParent())) forceDirectory(source.getParent()); + } + + private static void deleteDurably(Path target) throws IOException { + if (Files.deleteIfExists(target)) forceDirectory(target.getParent()); + } + + private static void forceDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } } @Override diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java index d1fa322e..95065cab 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -33,21 +33,30 @@ public final class InMemoryNodeRegistry implements NodeRegistry { private static final int MAX_CAPABILITIES = 64; private static final int MAX_DETECTED_PLUGINS = 128; private static final int MAX_BACKENDS = 4096; + private static final int MAX_TOTAL_BACKENDS = 65536; private final Map nodes = new ConcurrentHashMap<>(); private final Clock clock; private final Duration offlineTimeout; + private final int maxTotalBackends; + private int retainedBackends; public InMemoryNodeRegistry(Clock clock, Duration offlineTimeout) { + this(clock, offlineTimeout, MAX_TOTAL_BACKENDS); + } + + InMemoryNodeRegistry(Clock clock, Duration offlineTimeout, int maxTotalBackends) { this.clock = Objects.requireNonNull(clock, "clock"); this.offlineTimeout = Objects.requireNonNull(offlineTimeout, "offlineTimeout"); + if (maxTotalBackends < 1) throw new IllegalArgumentException("maxTotalBackends must be positive"); + this.maxTotalBackends = maxTotalBackends; if (offlineTimeout.isNegative() || offlineTimeout.isZero()) { throw new IllegalArgumentException("offlineTimeout must be positive"); } } @Override - public RegistrationResult register(NodeRegistration registration) { + public synchronized RegistrationResult register(NodeRegistration registration) { validate(registration); Instant now = clock.instant(); AtomicBoolean created = new AtomicBoolean(); @@ -56,6 +65,7 @@ public RegistrationResult register(NodeRegistration registration) { created.set(existing == null); List backends = existing != null && existing.sessionId.equals(registration.sessionId()) ? existing.backends : List.of(); + if (existing != null && backends.isEmpty()) retainedBackends -= existing.backends.size(); long sequence = existing != null && existing.sessionId.equals(registration.sessionId()) ? existing.snapshotSequence : -1L; StoredNode replacement = new StoredNode(registration.nodeId(), registration.sessionId(), @@ -95,7 +105,7 @@ public NodeStatus heartbeat(String nodeId, Heartbeat heartbeat) { } @Override - public SnapshotResult replacePresence(String nodeId, PresenceSnapshot snapshot) { + public synchronized SnapshotResult replacePresence(String nodeId, PresenceSnapshot snapshot) { validateId(nodeId, "nodeId"); validateSnapshot(snapshot); Instant now = clock.instant(); @@ -109,11 +119,17 @@ public SnapshotResult replacePresence(String nodeId, PresenceSnapshot snapshot) } List sorted = snapshot.backends().stream() .sorted(Comparator.comparing(BackendServerIdentity::backendId)).toList(); + int replacementTotal = retainedBackends - existing.backends.size() + sorted.size(); + if (replacementTotal > maxTotalBackends) { + throw new ValidationException("REGISTRY_LIMIT", "Backend snapshot exceeds registry capacity", + List.of(nodeId)); + } StoredNode updated = new StoredNode(existing.nodeId, existing.sessionId, existing.displayName, existing.platform, existing.pluginVersion, existing.protocolVersion, existing.advertisedCapabilities, existing.acceptedCapabilities, existing.detectedPlugins, sorted, snapshot.sequence(), now, now); applied.set(true); + retainedBackends = replacementTotal; result.set(updated); return updated; }); diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 9469a859..389398f7 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -199,7 +199,8 @@ private void handle(HttpExchange exchange) throws IOException { int status = switch (e.code()) { case "NODE_NOT_FOUND", "OPERATION_NOT_FOUND" -> 404; case "UNSUPPORTED_PROTOCOL", "INCOMPATIBLE_CAPABILITIES", "SESSION_MISMATCH", - "PREVIEW_INCOMPLETE", "APPROVAL_REQUIRED", "NODE_UNAVAILABLE", "OPERATION_LIMIT" -> 409; + "PREVIEW_INCOMPLETE", "APPROVAL_REQUIRED", "NODE_UNAVAILABLE", "OPERATION_LIMIT", + "REGISTRY_LIMIT" -> 409; case "TASK_NOT_CLAIMED" -> 409; case "UNSUPPORTED_MEDIA_TYPE" -> 415; default -> 400; diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index e60fa0b8..b2f18093 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -186,6 +186,7 @@ function updatePluginSuggestions() { } async function authorized(path, options = {}) { + const requestGeneration = authenticationGeneration; const method = (options.method || 'GET').toUpperCase(); const response = await fetch(path, { cache: 'no-store', @@ -194,7 +195,12 @@ async function authorized(path, options = {}) { headers: {...(options.headers || {}), ...(method === 'GET' ? {} : {'X-CSRF-Token': csrfToken})} }); const body = response.status === 204 ? null : await response.json(); - if (response.status === 401) discardAuthenticationState('Session expired. Sign in again.'); + if (response.status === 401 && requestGeneration === authenticationGeneration) { + discardAuthenticationState('Session expired. Sign in again.'); + } + if (requestGeneration !== authenticationGeneration) { + throw new Error('Authentication changed while the request was running. Try again.'); + } if (!response.ok) throw new Error(body?.error?.message || `Control request failed (${response.status}).`); return body; } @@ -392,10 +398,11 @@ async function restoreSession() { readConfiguration.addEventListener('click', async () => { approvedPreview = null; + const readGeneration = authenticationGeneration; try { const operation = await startConfigurationOperation('/api/v1/configuration/read', {nodeIds: targets('config.proxy-routing.v1')}); const retained = Object.values(operation.results).find(result => result.success && result.configuration); - if (retained) { + if (retained && authenticated && readGeneration === authenticationGeneration) { sendAll.checked = retained.configuration.sendVotesToAllServers; blockedServers.value = retained.configuration.blockedServers.join('\n'); } @@ -440,6 +447,7 @@ applyConfiguration.addEventListener('click', async () => { readFileConfiguration.addEventListener('click', async () => { approvedFilePreview = null; + const readGeneration = authenticationGeneration; try { const operation = await startConfigurationOperation('/api/v1/configuration/read', { nodeIds: targets('config.files.v1'), @@ -447,7 +455,7 @@ readFileConfiguration.addEventListener('click', async () => { }, fileOperationStatus); const contentResult = Object.values(operation.results).find(result => result.success && result.configuration?.content != null); - if (contentResult) { + if (contentResult && authenticated && readGeneration === authenticationGeneration) { configurationContent.value = contentResult.configuration.content; text(fileOperationStatus, operationSummary(operation)); inputGeneration++; diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java index 629dcacc..64d7e4c5 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java @@ -71,6 +71,23 @@ class InMemoryNodeRegistryTest { () -> registry.heartbeat("proxy-a", new Heartbeat(session, 1, Set.of(), Set.of()))).code()); } + @Test void registryWideBackendBudgetRejectsAtomicallyAndIsReclaimed() { + InMemoryNodeRegistry bounded = new InMemoryNodeRegistry(clock, Duration.ofSeconds(90), 3); + UUID secondSession = UUID.fromString("00000000-0000-0000-0000-000000000002"); + bounded.register(registration("proxy-a", session, Set.of("presence.snapshot"), Set.of())); + bounded.register(registration("proxy-b", secondSession, Set.of("presence.snapshot"), Set.of())); + bounded.replacePresence("proxy-a", snapshot(session, 1, backend("a"), backend("b"))); + + ValidationException full = assertThrows(ValidationException.class, () -> bounded.replacePresence( + "proxy-b", snapshot(secondSession, 1, backend("c"), backend("d")))); + assertEquals("REGISTRY_LIMIT", full.code()); + assertTrue(bounded.find("proxy-b").backends().isEmpty()); + + bounded.replacePresence("proxy-a", snapshot(session, 2, backend("a"))); + assertTrue(bounded.replacePresence("proxy-b", + snapshot(secondSession, 1, backend("c"), backend("d"))).applied()); + } + @Test void duplicateRegistrationIsAtomicUnderConcurrency() throws Exception { int count = 32; CountDownLatch ready = new CountDownLatch(count); @@ -131,7 +148,11 @@ private NodeRegistration registration(String nodeId, UUID registrationSession, S } private PresenceSnapshot snapshot(long sequence, BackendServerIdentity... backends) { - return new PresenceSnapshot(session, 1, sequence, List.of(backends)); + return snapshot(session, sequence, backends); + } + + private PresenceSnapshot snapshot(UUID snapshotSession, long sequence, BackendServerIdentity... backends) { + return new PresenceSnapshot(snapshotSession, 1, sequence, List.of(backends)); } private static BackendServerIdentity backend(String id) { From f16ab283de8083c41b90e8ab01657dd809cb01c3 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 25 Aug 2026 19:05:22 -0600 Subject: [PATCH 24/42] Protect file selection and operation leases --- .../control/auth/CredentialStore.java | 6 ++++++ .../domain/ConfigurationOperations.java | 4 +++- src/main/resources/web/app.js | 16 ++++++++++++---- .../domain/ConfigurationOperationsTest.java | 19 +++++++++++++++++++ 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java b/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java index dad74c2a..296094d3 100644 --- a/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java +++ b/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java @@ -229,12 +229,18 @@ private synchronized void mutate(Mutation mutation) throws IOException { Path temporary = Files.createTempFile(directory, "credentials-", ".tmp"); try { Files.write(temporary, bytes, StandardOpenOption.TRUNCATE_EXISTING); + try (FileChannel temporaryChannel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + temporaryChannel.force(true); + } try { Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } catch (java.nio.file.AtomicMoveNotSupportedException e) { Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); } + try (FileChannel directoryChannel = FileChannel.open(directory, StandardOpenOption.READ)) { + directoryChannel.force(true); + } } finally { Files.deleteIfExists(temporary); } diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 97709818..0a45b170 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -304,7 +304,9 @@ private void prune() { Iterator> iterator = operations.entrySet().iterator(); while (iterator.hasNext()) { StoredOperation operation = iterator.next().getValue(); - if (operation.createdAt.isBefore(operation.complete() ? completedCutoff : activeCutoff)) { + boolean activeLease = operation.leasedAt.values().stream() + .anyMatch(leased -> now.isBefore(leased.plus(LEASE))); + if (!activeLease && operation.createdAt.isBefore(operation.complete() ? completedCutoff : activeCutoff)) { audit.append("OPERATION_EXPIRED", operation.id, null, operation.complete() ? "RETAINED" : "ABANDONED"); releaseResultDetails(operation); diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index b2f18093..29a24a75 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -447,15 +447,18 @@ applyConfiguration.addEventListener('click', async () => { readFileConfiguration.addEventListener('click', async () => { approvedFilePreview = null; - const readGeneration = authenticationGeneration; + const readAuthenticationGeneration = authenticationGeneration; + const readInputGeneration = inputGeneration; + const selectedFile = configurationFile.value; try { const operation = await startConfigurationOperation('/api/v1/configuration/read', { nodeIds: targets('config.files.v1'), - configuration: {domain: 'file', fileName: configurationFile.value} + configuration: {domain: 'file', fileName: selectedFile} }, fileOperationStatus); const contentResult = Object.values(operation.results).find(result => result.success && result.configuration?.content != null); - if (contentResult && authenticated && readGeneration === authenticationGeneration) { + if (contentResult && authenticated && readAuthenticationGeneration === authenticationGeneration + && readInputGeneration === inputGeneration && selectedFile === configurationFile.value) { configurationContent.value = contentResult.configuration.content; text(fileOperationStatus, operationSummary(operation)); inputGeneration++; @@ -548,10 +551,15 @@ applyQuickSetup.addEventListener('click', async () => { } catch (error) { text(quickOperationStatus, error.message); } }); -[configurationFile, configurationContent, quickName, quickService, quickUrl, quickDelay, quickRewardScope, +[configurationContent, quickName, quickService, quickUrl, quickDelay, quickRewardScope, quickCommand, quickMessage, quickProcessRewards, quickAutoSites, quickExtraCheck, quickCountFake, quickHideSiteWarning, quickDisableUpdates, quickPartyVotes, quickPartyCommand, quickPartyBroadcast, quickPartyAll, quickPartyOnline].forEach(field => field.addEventListener('input', clearApprovals)); +configurationFile.addEventListener('input', () => { + configurationContent.value = ''; + text(fileOperationStatus, 'Read the selected file before previewing changes.'); + clearApprovals(); +}); quickPreset.addEventListener('input', () => { updateQuickFields(); clearApprovals(); }); refresh.addEventListener('click', loadNodes); previousPage.addEventListener('click', () => { diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 0ad9b520..7018db2e 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -303,6 +303,25 @@ class ConfigurationOperationsTest { assertEquals("APPLY", operations.createApply(previewId, approval).type()); } + @Test void activeTaskLeasePostponesOperationAbandonmentUntilLeaseExpires() throws Exception { + MutableClock clock = new MutableClock(Instant.parse("2026-08-25T00:00:00Z")); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofHours(1)); + UUID session = UUID.randomUUID(); + registry.register(registration(session)); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + UUID operation = operations.createRead(List.of("proxy-a")).operationId(); + + clock.advance(Duration.ofMinutes(14)); + assertNotNull(operations.claim("proxy-a", session)); + clock.advance(Duration.ofSeconds(90)); + assertEquals("RUNNING", operations.get(operation).state()); + + clock.advance(Duration.ofSeconds(31)); + assertEquals("OPERATION_NOT_FOUND", assertThrows(ValidationException.class, + () -> operations.get(operation)).code()); + } + @Test void fileAndQuickSetupOperationsRequireTheirNegotiatedCapabilities() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); From 30900fd48329a7fcd75dc8a6fdd5ebd2eb43d484 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 25 Aug 2026 19:20:44 -0600 Subject: [PATCH 25/42] Bound registry and result metadata --- README.md | 3 ++- .../domain/ConfigurationOperations.java | 3 ++- .../control/domain/InMemoryNodeRegistry.java | 20 +++++++++++++++- src/main/resources/web/app.js | 6 +++-- .../domain/ConfigurationOperationsTest.java | 15 ++++++++++++ .../domain/InMemoryNodeRegistryTest.java | 24 ++++++++++++++++++- 6 files changed, 65 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b0e802f9..e88ff92d 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,8 @@ Configuration is split into independently negotiated capabilities. `config.proxy `config.files.v1` manages `Config.yml`, `VoteSites.yml`, `SpecialRewards.yml`, `GUI.yml`, `Shop.yml`, and `BungeeSettings.yml` on enrolled Bukkit nodes through a bounded YAML editor. `config.quick-setup.v1` supplies standalone, proxy-backend, vote-site, easy-reward, common-settings, and vote-party presets. Bukkit registration also reports a bounded -set of installed plugin names so the WebUI can offer editable Minecraft, Essentials, CMI, and LuckPerms command suggestions; +set of installed plugin names (at most 16384 entries across the registry) so the WebUI can offer editable Minecraft, +Essentials, CMI, and LuckPerms command suggestions; these are suggestions, not executed commands, and follow the normal preview/approval path. Password, secret, token, API-key, authorization, and webhook-secret values are masked on every read; leaving the redaction marker in a proposal preserves the current value. A newly entered secret is accepted only in the authenticated proposal and is not returned in results or written to the audit log. diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 0a45b170..9f63584b 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -323,7 +323,8 @@ private static void validateResult(ConfigurationTaskResult result) { if (result == null || result.sessionId() == null || result.code() == null || !result.code().matches("[A-Z][A-Z0-9_]{0,63}") || result.message() == null || result.message().isBlank() || result.message().length() > 500 || result.changes().size() > 20 - || result.changes().stream().anyMatch(value -> value == null || value.length() > 500)) { + || result.changes().stream().anyMatch(value -> value == null || value.length() > 500) + || (result.revision() != null && !result.revision().matches("[0-9a-f]{64}"))) { throw invalid("result is invalid"); } if (result.success() && (result.revision() == null || !result.revision().matches("[0-9a-f]{64}") diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java index 95065cab..74968d97 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -34,22 +34,32 @@ public final class InMemoryNodeRegistry implements NodeRegistry { private static final int MAX_DETECTED_PLUGINS = 128; private static final int MAX_BACKENDS = 4096; private static final int MAX_TOTAL_BACKENDS = 65536; + private static final int MAX_TOTAL_DETECTED_PLUGINS = 16384; private final Map nodes = new ConcurrentHashMap<>(); private final Clock clock; private final Duration offlineTimeout; private final int maxTotalBackends; + private final int maxTotalDetectedPlugins; private int retainedBackends; + private int retainedDetectedPlugins; public InMemoryNodeRegistry(Clock clock, Duration offlineTimeout) { - this(clock, offlineTimeout, MAX_TOTAL_BACKENDS); + this(clock, offlineTimeout, MAX_TOTAL_BACKENDS, MAX_TOTAL_DETECTED_PLUGINS); } InMemoryNodeRegistry(Clock clock, Duration offlineTimeout, int maxTotalBackends) { + this(clock, offlineTimeout, maxTotalBackends, MAX_TOTAL_DETECTED_PLUGINS); + } + + InMemoryNodeRegistry(Clock clock, Duration offlineTimeout, int maxTotalBackends, + int maxTotalDetectedPlugins) { this.clock = Objects.requireNonNull(clock, "clock"); this.offlineTimeout = Objects.requireNonNull(offlineTimeout, "offlineTimeout"); if (maxTotalBackends < 1) throw new IllegalArgumentException("maxTotalBackends must be positive"); + if (maxTotalDetectedPlugins < 1) throw new IllegalArgumentException("maxTotalDetectedPlugins must be positive"); this.maxTotalBackends = maxTotalBackends; + this.maxTotalDetectedPlugins = maxTotalDetectedPlugins; if (offlineTimeout.isNegative() || offlineTimeout.isZero()) { throw new IllegalArgumentException("offlineTimeout must be positive"); } @@ -59,6 +69,13 @@ public InMemoryNodeRegistry(Clock clock, Duration offlineTimeout) { public synchronized RegistrationResult register(NodeRegistration registration) { validate(registration); Instant now = clock.instant(); + StoredNode current = nodes.get(registration.nodeId()); + int replacementPluginTotal = retainedDetectedPlugins + - (current == null ? 0 : current.detectedPlugins.size()) + registration.detectedPlugins().size(); + if (replacementPluginTotal > maxTotalDetectedPlugins) { + throw new ValidationException("REGISTRY_LIMIT", "Plugin inventory exceeds registry capacity", + List.of(registration.nodeId())); + } AtomicBoolean created = new AtomicBoolean(); AtomicReference result = new AtomicReference<>(); nodes.compute(registration.nodeId(), (ignored, existing) -> { @@ -75,6 +92,7 @@ public synchronized RegistrationResult register(NodeRegistration registration) { result.set(replacement); return replacement; }); + retainedDetectedPlugins = replacementPluginTotal; return new RegistrationResult(view(result.get()), created.get()); } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 29a24a75..4fbac245 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -398,11 +398,13 @@ async function restoreSession() { readConfiguration.addEventListener('click', async () => { approvedPreview = null; - const readGeneration = authenticationGeneration; + const readAuthenticationGeneration = authenticationGeneration; + const readInputGeneration = inputGeneration; try { const operation = await startConfigurationOperation('/api/v1/configuration/read', {nodeIds: targets('config.proxy-routing.v1')}); const retained = Object.values(operation.results).find(result => result.success && result.configuration); - if (retained && authenticated && readGeneration === authenticationGeneration) { + if (retained && authenticated && readAuthenticationGeneration === authenticationGeneration + && readInputGeneration === inputGeneration) { sendAll.checked = retained.configuration.sendVotesToAllServers; blockedServers.value = retained.configuration.blockedServers.join('\n'); } diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 7018db2e..cdf387df 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -322,6 +322,21 @@ class ConfigurationOperationsTest { () -> operations.get(operation)).code()); } + @Test void failedTaskResultCannotRetainAnUnboundedRevision() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofHours(1)); + UUID session = UUID.randomUUID(); + registry.register(registration(session)); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + UUID operation = operations.createRead(List.of("proxy-a")).operationId(); + operations.claim("proxy-a", session); + + assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class, () -> operations.complete(operation, + "proxy-a", new ConfigurationTaskResult(session, false, "FAILED", "failed", "x".repeat(1000), + (ManagedConfiguration) null, List.of(), false, false))).code()); + } + @Test void fileAndQuickSetupOperationsRequireTheirNegotiatedCapabilities() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java index 64d7e4c5..1ec1a455 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java @@ -88,6 +88,23 @@ class InMemoryNodeRegistryTest { snapshot(secondSession, 1, backend("c"), backend("d"))).applied()); } + @Test void registryWidePluginInventoryBudgetRejectsAtomicallyAndIsReclaimed() { + InMemoryNodeRegistry bounded = new InMemoryNodeRegistry(clock, Duration.ofSeconds(90), 10, 3); + UUID secondSession = UUID.fromString("00000000-0000-0000-0000-000000000002"); + bounded.register(registration("proxy-a", session, Set.of(), Set.of(), Set.of("One", "Two"))); + bounded.register(registration("proxy-b", secondSession, Set.of(), Set.of(), Set.of("Three"))); + + ValidationException full = assertThrows(ValidationException.class, () -> bounded.register( + registration("proxy-a", session, Set.of(), Set.of(), Set.of("One", "Two", "Four")))); + assertEquals("REGISTRY_LIMIT", full.code()); + assertEquals(Set.of("One", "Two"), bounded.find("proxy-a").detectedPlugins()); + + bounded.register(registration("proxy-b", secondSession, Set.of(), Set.of(), Set.of())); + assertEquals(Set.of("One", "Two", "Four"), bounded.register( + registration("proxy-a", session, Set.of(), Set.of(), Set.of("One", "Two", "Four"))) + .node().detectedPlugins()); + } + @Test void duplicateRegistrationIsAtomicUnderConcurrency() throws Exception { int count = 32; CountDownLatch ready = new CountDownLatch(count); @@ -143,8 +160,13 @@ class InMemoryNodeRegistryTest { private NodeRegistration registration(String nodeId, UUID registrationSession, Set capabilities, Set required) { + return registration(nodeId, registrationSession, capabilities, required, Set.of()); + } + + private NodeRegistration registration(String nodeId, UUID registrationSession, Set capabilities, + Set required, Set detectedPlugins) { return new NodeRegistration(nodeId, registrationSession, "Proxy A", "VELOCITY", "7.1.2", 1, - capabilities, required); + capabilities, required, detectedPlugins); } private PresenceSnapshot snapshot(long sequence, BackendServerIdentity... backends) { From 1d675c7f79b01d5e22ef498589cf416c1fda18bc Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 25 Aug 2026 19:42:48 -0600 Subject: [PATCH 26/42] Bound retained results and identity writes --- .../control/ControlApplication.java | 22 +++++++++++-- .../domain/ConfigurationOperations.java | 28 ++++++++++++++-- src/main/resources/web/app.js | 16 +++++++-- .../domain/ConfigurationOperationsTest.java | 33 ++++++++++++++++++- 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java index 23aeb7c4..5e3254cc 100644 --- a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java +++ b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java @@ -13,8 +13,10 @@ import java.net.InetSocketAddress; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.time.Clock; import java.time.Duration; @@ -165,8 +167,24 @@ static UUID loadIdentity(Path directory) throws IOException { return readIdentity(file); } UUID candidate = UUID.randomUUID(); - Files.writeString(file, candidate.toString(), StandardOpenOption.CREATE_NEW, - StandardOpenOption.WRITE); + Path temporary = Files.createTempFile(directory, "instance-id-", ".temporary"); + try { + Files.writeString(temporary, candidate.toString(), StandardCharsets.UTF_8, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + try (FileChannel identity = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + identity.force(true); + } + try { + Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + Files.move(temporary, file); + } + try (FileChannel dataDirectory = FileChannel.open(directory, StandardOpenOption.READ)) { + dataDirectory.force(true); + } + } finally { + Files.deleteIfExists(temporary); + } return candidate; } } diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 9f63584b..c6266570 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -30,6 +30,7 @@ public final class ConfigurationOperations implements AutoCloseable { private static final int MAX_OPERATIONS = 1000; private static final int MAX_FILE_OPERATIONS = 16; static final int MAX_RETAINED_CHANGE_BYTES = 256 * 1024; + static final int MAX_RETAINED_MESSAGE_BYTES = 256 * 1024; private static final Duration LEASE = Duration.ofMinutes(2); private static final Duration ACTIVE_RETENTION = Duration.ofMinutes(15); private static final Duration RETENTION = Duration.ofHours(24); @@ -40,6 +41,7 @@ public final class ConfigurationOperations implements AutoCloseable { private final SecureRandom random = new SecureRandom(); private final LinkedHashMap operations = new LinkedHashMap<>(); private long retainedChangeBytes; + private long retainedMessageBytes; public ConfigurationOperations(NodeRegistry registry, ConfigurationAuditLog audit, Clock clock) { this.registry = Objects.requireNonNull(registry); @@ -116,9 +118,9 @@ private ConfigurationTask claimCurrentSession(String nodeId, NodeStatus node) { && !now.isBefore(leased.plus(LEASE)))) { if (!node.online() || !node.acceptedCapabilities().contains(operation.configuration.capability())) { audit.append("TASK_CANCELLED", operation.id, nodeId, "CAPABILITY_LOST"); - operation.results.put(nodeId, new ConfigurationTaskResult(sessionId(node), false, + operation.results.put(nodeId, boundedResult(operation, new ConfigurationTaskResult(sessionId(node), false, "CAPABILITY_LOST", "Node no longer accepts this configuration capability", null, - (ManagedConfiguration) null, List.of(), false, false)); + (ManagedConfiguration) null, List.of(), false, false))); operation.states.put(nodeId, "COMPLETE"); operation.leasedAt.remove(nodeId); continue; @@ -158,6 +160,7 @@ private OperationView completeCurrentSession(UUID operationId, String nodeId, Co if (!"IN_PROGRESS".equals(operation.states.get(nodeId))) { throw new ValidationException("TASK_NOT_CLAIMED", "Operation task must be claimed before completion", List.of()); } + validateResultConfiguration(operation, result); audit.append("TASK_COMPLETED", operation.id, nodeId, result.success() ? "SUCCESS" : result.code()); operation.results.put(nodeId, boundedResult(operation, result)); operation.states.put(nodeId, "COMPLETE"); @@ -221,10 +224,27 @@ private ConfigurationTaskResult boundedResult(StoredOperation operation, Configu configuration = null; } List changes = retainChanges(result.changes()); - return new ConfigurationTaskResult(result.sessionId(), result.success(), result.code(), result.message(), + return new ConfigurationTaskResult(result.sessionId(), result.success(), result.code(), retainMessage(result.message()), result.revision(), configuration, changes, result.reloaded(), result.rolledBack()); } + private static void validateResultConfiguration(StoredOperation operation, ConfigurationTaskResult result) { + ManagedConfiguration actual = result.configuration(); + if (actual == null) return; + ManagedConfiguration expected = operation.configuration; + boolean mismatch = expected == null || !expected.domain().equals(actual.domain()) + || (ManagedConfiguration.FILE.equals(expected.domain()) && !expected.fileName().equals(actual.fileName())) + || (ManagedConfiguration.QUICK_SETUP.equals(expected.domain()) && !expected.preset().equals(actual.preset())); + if (mismatch) throw invalid("result configuration does not match the operation selector"); + } + + private String retainMessage(String message) { + int remaining = (int) Math.max(0, MAX_RETAINED_MESSAGE_BYTES - retainedMessageBytes); + String retained = truncateUtf8(message, remaining); + retainedMessageBytes += retained.getBytes(StandardCharsets.UTF_8).length; + return retained; + } + private List retainChanges(List changes) { long remaining = Math.max(0, MAX_RETAINED_CHANGE_BYTES - retainedChangeBytes); if (remaining == 0 || changes.isEmpty()) return List.of(); @@ -264,11 +284,13 @@ private static String truncateUtf8(String value, int maximumBytes) { private void releaseResultDetails(StoredOperation operation) { for (ConfigurationTaskResult result : operation.results.values()) { + retainedMessageBytes -= result.message().getBytes(StandardCharsets.UTF_8).length; for (String change : result.changes()) { retainedChangeBytes -= change.getBytes(StandardCharsets.UTF_8).length; } } if (retainedChangeBytes < 0) retainedChangeBytes = 0; + if (retainedMessageBytes < 0) retainedMessageBytes = 0; } private List validateTargets(List nodeIds, String capability) { diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 4fbac245..580967b1 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -378,8 +378,20 @@ form.addEventListener('submit', async event => { }); logout.addEventListener('click', async () => { - try { await authorized('/api/v1/auth/logout', {method: 'POST'}); } catch (_) { /* Expired is logged out too. */ } - discardAuthenticationState('Signed out.'); + if (loginInFlight) return; + loginInFlight = true; + const logoutGeneration = ++authenticationGeneration; + loginButton.disabled = true; + logout.disabled = true; + try { + await authorized('/api/v1/auth/logout', {method: 'POST'}); + } catch (_) { /* Expired is logged out too. */ } + finally { + if (logoutGeneration === authenticationGeneration) discardAuthenticationState('Signed out.'); + loginInFlight = false; + loginButton.disabled = false; + logout.disabled = false; + } }); async function restoreSession() { diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index cdf387df..6db8a13d 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -337,6 +337,21 @@ class ConfigurationOperationsTest { (ManagedConfiguration) null, List.of(), false, false))).code()); } + @Test void taskResultConfigurationMustMatchItsOperationDomainAndSelector() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofHours(1)); + UUID session = UUID.randomUUID(); + registry.register(registration(session)); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + UUID operation = operations.createRead(List.of("proxy-a")).operationId(); + operations.claim("proxy-a", session); + + assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class, () -> operations.complete(operation, + "proxy-a", new ConfigurationTaskResult(session, true, "OK", "read", "a".repeat(64), + ManagedConfiguration.file("Config.yml", "large: value\n"), List.of(), false, false))).code()); + } + @Test void fileAndQuickSetupOperationsRequireTheirNegotiatedCapabilities() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); @@ -409,7 +424,7 @@ class ConfigurationOperationsTest { () -> operations.createRead(List.of("backend-a"), selector)).code()); } - @Test void aggregateRetainedChangeDetailsHaveAGlobalByteBudget() throws Exception { + @Test void aggregateRetainedResultDetailsHaveGlobalByteBudgets() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); List nodes = new java.util.ArrayList<>(); @@ -439,6 +454,22 @@ class ConfigurationOperationsTest { assertTrue(retainedBytes <= ConfigurationOperations.MAX_RETAINED_CHANGE_BYTES); assertTrue(retainedBytes < 100L * 20 * 500); assertEquals(1, read.results().values().stream().filter(result -> result.configuration() != null).count()); + + List messageOperations = new java.util.ArrayList<>(); + for (int operationIndex = 0; operationIndex < 6; operationIndex++) { + ConfigurationOperations.OperationView messages = operations.createRead(nodes); + messageOperations.add(messages.operationId()); + for (String node : nodes) { + operations.claim(node, sessions.get(node)); + operations.complete(messages.operationId(), node, new ConfigurationTaskResult(sessions.get(node), + true, "OK", "m".repeat(500), "b".repeat(64), configuration, List.of(), false, false)); + } + } + long retainedMessages = messageOperations.stream().map(operations::get) + .flatMap(operation -> operation.results().values().stream()) + .mapToLong(result -> result.message().getBytes(java.nio.charset.StandardCharsets.UTF_8).length).sum(); + assertTrue(retainedMessages <= ConfigurationOperations.MAX_RETAINED_MESSAGE_BYTES); + assertTrue(retainedMessages < 6L * 100 * 500); } private static final class MutableClock extends Clock { From 6ccb6041ce5fd579dd2e42473494dd7c19b0d2fd Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 25 Aug 2026 19:53:48 -0600 Subject: [PATCH 27/42] Enforce configuration variants and stale eviction --- .../control/domain/InMemoryNodeRegistry.java | 18 ++++++++++++++++++ .../control/protocol/ManagedConfiguration.java | 8 +++++++- .../domain/ConfigurationOperationsTest.java | 9 +++++++++ .../domain/InMemoryNodeRegistryTest.java | 11 +++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java index 74968d97..787ad500 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -69,6 +69,7 @@ public InMemoryNodeRegistry(Clock clock, Duration offlineTimeout) { public synchronized RegistrationResult register(NodeRegistration registration) { validate(registration); Instant now = clock.instant(); + reclaimOfflineTopology(now, registration.nodeId()); StoredNode current = nodes.get(registration.nodeId()); int replacementPluginTotal = retainedDetectedPlugins - (current == null ? 0 : current.detectedPlugins.size()) + registration.detectedPlugins().size(); @@ -127,6 +128,7 @@ public synchronized SnapshotResult replacePresence(String nodeId, PresenceSnapsh validateId(nodeId, "nodeId"); validateSnapshot(snapshot); Instant now = clock.instant(); + reclaimOfflineTopology(now, nodeId); AtomicBoolean applied = new AtomicBoolean(); AtomicReference result = new AtomicReference<>(); nodes.computeIfPresent(nodeId, (ignored, existing) -> { @@ -208,6 +210,22 @@ private NodeStatus view(StoredNode node) { node.backends, node.snapshotSequence, node.lastSeen, node.lastAuthenticatedUpdate, online); } + private void reclaimOfflineTopology(Instant now, String excludedNodeId) { + for (String candidate : List.copyOf(nodes.keySet())) { + if (candidate.equals(excludedNodeId)) continue; + nodes.computeIfPresent(candidate, (ignored, existing) -> { + if (now.isBefore(existing.lastSeen.plus(offlineTimeout)) + || (existing.detectedPlugins.isEmpty() && existing.backends.isEmpty())) return existing; + retainedDetectedPlugins -= existing.detectedPlugins.size(); + retainedBackends -= existing.backends.size(); + return new StoredNode(existing.nodeId, existing.sessionId, existing.displayName, existing.platform, + existing.pluginVersion, existing.protocolVersion, existing.advertisedCapabilities, + existing.acceptedCapabilities, Set.of(), List.of(), existing.snapshotSequence, + existing.lastSeen, existing.lastAuthenticatedUpdate); + }); + } + } + private static void validate(NodeRegistration registration) { if (registration == null) { throw invalid("registration is required"); diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java index 7bd62ab7..e10bf0df 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java @@ -20,14 +20,20 @@ public record ManagedConfiguration(String domain, Boolean sendVotesToAllServers, throw new IllegalArgumentException("configuration domain is unsupported"); } if (PROXY_ROUTING.equals(domain)) { - if (sendVotesToAllServers == null) throw new IllegalArgumentException("proxy routing is incomplete"); + if (sendVotesToAllServers == null || fileName != null || content != null || preset != null || !options.isEmpty()) + throw new IllegalArgumentException("proxy routing contains fields from another domain"); new ProxyRoutingConfiguration(sendVotesToAllServers, blockedServers); } else if (FILE.equals(domain)) { + if (sendVotesToAllServers != null || !blockedServers.isEmpty() || preset != null || !options.isEmpty()) + throw new IllegalArgumentException("file configuration contains fields from another domain"); validateFileName(fileName); if (content != null && (content.length() > MAX_CONTENT || content.indexOf('\0') >= 0)) { throw new IllegalArgumentException("configuration file content is invalid"); } } else { + if (sendVotesToAllServers != null || !blockedServers.isEmpty() || fileName != null || content != null) { + throw new IllegalArgumentException("quick setup contains fields from another domain"); + } if (preset == null || !preset.matches("[a-z][a-z0-9-]{0,39}")) { throw new IllegalArgumentException("quick setup preset is invalid"); } diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 6db8a13d..14b3e22b 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -352,6 +352,15 @@ class ConfigurationOperationsTest { ManagedConfiguration.file("Config.yml", "large: value\n"), List.of(), false, false))).code()); } + @Test void managedConfigurationRejectsFieldsFromAnotherUnionDomain() { + assertThrows(IllegalArgumentException.class, () -> new ManagedConfiguration( + ManagedConfiguration.PROXY_ROUTING, false, List.of(), null, "hidden", null, Map.of())); + assertThrows(IllegalArgumentException.class, () -> new ManagedConfiguration( + ManagedConfiguration.QUICK_SETUP, null, List.of(), null, "hidden", "standalone", Map.of())); + assertThrows(IllegalArgumentException.class, () -> new ManagedConfiguration( + ManagedConfiguration.FILE, null, List.of("other"), "Config.yml", null, null, Map.of())); + } + @Test void fileAndQuickSetupOperationsRequireTheirNegotiatedCapabilities() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java index 1ec1a455..56384cf6 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java @@ -105,6 +105,17 @@ class InMemoryNodeRegistryTest { .node().detectedPlugins()); } + @Test void staleNodeInventoriesReleaseRegistryWideCapacity() { + InMemoryNodeRegistry bounded = new InMemoryNodeRegistry(clock, Duration.ofSeconds(90), 10, 3); + bounded.register(registration("proxy-a", session, Set.of(), Set.of(), Set.of("One", "Two", "Three"))); + + clock.advance(Duration.ofSeconds(90)); + bounded.register(registration("proxy-b", UUID.randomUUID(), Set.of(), Set.of(), Set.of("Four"))); + + assertTrue(bounded.find("proxy-a").detectedPlugins().isEmpty()); + assertEquals(Set.of("Four"), bounded.find("proxy-b").detectedPlugins()); + } + @Test void duplicateRegistrationIsAtomicUnderConcurrency() throws Exception { int count = 32; CountDownLatch ready = new CountDownLatch(count); From 0626bf07fd3b623dcb0dd919b478fb458bd34a4f Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 25 Aug 2026 20:05:06 -0600 Subject: [PATCH 28/42] Handle portable durability and stale registrations --- .../control/ControlApplication.java | 4 +--- .../votingplugin/control/DurableFiles.java | 23 +++++++++++++++++++ .../control/auth/CredentialStore.java | 5 ++-- .../control/domain/ConfigurationAuditLog.java | 5 ++-- .../control/domain/InMemoryNodeRegistry.java | 5 +--- .../control/ControlApplicationTest.java | 5 ++++ .../domain/InMemoryNodeRegistryTest.java | 5 +++- 7 files changed, 38 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/bencodez/votingplugin/control/DurableFiles.java diff --git a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java index 5e3254cc..96ee96de 100644 --- a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java +++ b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java @@ -179,9 +179,7 @@ static UUID loadIdentity(Path directory) throws IOException { } catch (java.nio.file.AtomicMoveNotSupportedException e) { Files.move(temporary, file); } - try (FileChannel dataDirectory = FileChannel.open(directory, StandardOpenOption.READ)) { - dataDirectory.force(true); - } + DurableFiles.forceDirectory(directory); } finally { Files.deleteIfExists(temporary); } diff --git a/src/main/java/com/bencodez/votingplugin/control/DurableFiles.java b/src/main/java/com/bencodez/votingplugin/control/DurableFiles.java new file mode 100644 index 00000000..76ee4714 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/DurableFiles.java @@ -0,0 +1,23 @@ +package com.bencodez.votingplugin.control; + +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Locale; + +/** Filesystem durability helpers with explicit handling for unsupported directory channels. */ +public final class DurableFiles { + private DurableFiles() { } + + public static void forceDirectory(Path directory) throws IOException { + if (!supportsDirectoryChannels(System.getProperty("os.name", ""))) return; + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); + } + } + + static boolean supportsDirectoryChannels(String operatingSystem) { + return !operatingSystem.toLowerCase(Locale.ROOT).contains("windows"); + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java b/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java index 296094d3..7b7e7bfc 100644 --- a/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java +++ b/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java @@ -1,5 +1,6 @@ package com.bencodez.votingplugin.control.auth; +import com.bencodez.votingplugin.control.DurableFiles; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; @@ -238,9 +239,7 @@ private synchronized void mutate(Mutation mutation) throws IOException { } catch (java.nio.file.AtomicMoveNotSupportedException e) { Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); } - try (FileChannel directoryChannel = FileChannel.open(directory, StandardOpenOption.READ)) { - directoryChannel.force(true); - } + DurableFiles.forceDirectory(directory); } finally { Files.deleteIfExists(temporary); } diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java index 125fcf52..13b18902 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java @@ -1,5 +1,6 @@ package com.bencodez.votingplugin.control.domain; +import com.bencodez.votingplugin.control.DurableFiles; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.JsonNode; import java.io.IOException; @@ -376,9 +377,7 @@ private static void deleteDurably(Path target) throws IOException { } private static void forceDirectory(Path directory) throws IOException { - try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { - channel.force(true); - } + DurableFiles.forceDirectory(directory); } @Override diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java index 787ad500..54fd6292 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -218,10 +218,7 @@ private void reclaimOfflineTopology(Instant now, String excludedNodeId) { || (existing.detectedPlugins.isEmpty() && existing.backends.isEmpty())) return existing; retainedDetectedPlugins -= existing.detectedPlugins.size(); retainedBackends -= existing.backends.size(); - return new StoredNode(existing.nodeId, existing.sessionId, existing.displayName, existing.platform, - existing.pluginVersion, existing.protocolVersion, existing.advertisedCapabilities, - existing.acceptedCapabilities, Set.of(), List.of(), existing.snapshotSequence, - existing.lastSeen, existing.lastAuthenticatedUpdate); + return null; }); } } diff --git a/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java index dd50b213..504611f8 100644 --- a/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java @@ -54,6 +54,11 @@ class ControlApplicationTest { assertThrows(java.io.IOException.class, () -> ControlApplication.loadIdentity(directory)); } + @Test void directoryDurabilityRecognizesWindowsAsUnsupported() { + assertFalse(DurableFiles.supportsDirectoryChannels("Windows Server 2025")); + assertTrue(DurableFiles.supportsDirectoryChannels("Linux")); + } + @Test void explicitAdminDirectoryDoesNotTouchConfiguredDefault() throws Exception { Path configuredDefault = directory.resolve("unwritable-default"); Path explicit = directory.resolve("explicit"); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java index 56384cf6..047758bc 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java @@ -112,7 +112,10 @@ class InMemoryNodeRegistryTest { clock.advance(Duration.ofSeconds(90)); bounded.register(registration("proxy-b", UUID.randomUUID(), Set.of(), Set.of(), Set.of("Four"))); - assertTrue(bounded.find("proxy-a").detectedPlugins().isEmpty()); + assertNull(bounded.find("proxy-a")); + ValidationException missing = assertThrows(ValidationException.class, () -> bounded.heartbeat("proxy-a", + new Heartbeat(session, 1, Set.of(), Set.of()))); + assertEquals("NODE_NOT_FOUND", missing.code()); assertEquals(Set.of("Four"), bounded.find("proxy-b").detectedPlugins()); } From df616e2f721620527f1a6d50165266f76f327f9a Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 25 Aug 2026 20:57:27 -0600 Subject: [PATCH 29/42] Reserve read details for successful results --- .../domain/ConfigurationOperations.java | 4 ++- .../domain/ConfigurationOperationsTest.java | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index c6266570..aa2c9fe9 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -215,11 +215,13 @@ private void ensureFileCapacity(ManagedConfiguration config, UUID protectedOpera private ConfigurationTaskResult boundedResult(StoredOperation operation, ConfigurationTaskResult result) { ManagedConfiguration configuration = result.configuration(); if (configuration != null && ManagedConfiguration.FILE.equals(configuration.domain())) { - boolean keepContent = "READ".equals(operation.type) && operation.results.values().stream() + boolean keepContent = result.success() && "READ".equals(operation.type) + && operation.results.values().stream().filter(ConfigurationTaskResult::success) .map(ConfigurationTaskResult::configuration).filter(Objects::nonNull) .noneMatch(value -> ManagedConfiguration.FILE.equals(value.domain()) && value.content() != null); if (!keepContent) configuration = configuration.publicView(); } else if (configuration != null && operation.results.values().stream() + .filter(ConfigurationTaskResult::success) .anyMatch(existing -> existing.configuration() != null)) { configuration = null; } diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 14b3e22b..ab2d3772 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -481,6 +481,32 @@ class ConfigurationOperationsTest { assertTrue(retainedMessages < 6L * 100 * 500); } + @Test void failedReadCannotConsumeTheSuccessfulConfigurationSlot() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID first = UUID.randomUUID(); + UUID second = UUID.randomUUID(); + registry.register(new NodeRegistration("backend-a", first, "Backend A", "BUKKIT", "test", 1, + Set.of(ConfigurationOperations.FILE_CAPABILITY), Set.of())); + registry.register(new NodeRegistration("backend-b", second, "Backend B", "BUKKIT", "test", 1, + Set.of(ConfigurationOperations.FILE_CAPABILITY), Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + ManagedConfiguration selector = ManagedConfiguration.file("Config.yml", null); + ConfigurationOperations.OperationView read = operations.createRead(List.of("backend-a", "backend-b"), selector); + ManagedConfiguration content = ManagedConfiguration.file("Config.yml", "Feature: true\n"); + + operations.claim("backend-a", first); + operations.complete(read.operationId(), "backend-a", new ConfigurationTaskResult(first, false, "READ_FAILED", + "failed", "a".repeat(64), content, List.of(), false, false)); + operations.claim("backend-b", second); + read = operations.complete(read.operationId(), "backend-b", new ConfigurationTaskResult(second, true, "OK", + "read", "b".repeat(64), content, List.of(), false, false)); + + assertEquals(null, read.results().get("backend-a").configuration().content()); + assertEquals("Feature: true\n", read.results().get("backend-b").configuration().content()); + } + private static final class MutableClock extends Clock { private Instant instant; private MutableClock(Instant instant) { this.instant = instant; } From 3ca5f1c5dc0ae049a9cca2209f66bcce830fc7e7 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 25 Aug 2026 21:04:39 -0600 Subject: [PATCH 30/42] Drop failed configuration payloads --- .../votingplugin/control/domain/ConfigurationOperations.java | 2 +- .../control/domain/ConfigurationOperationsTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index aa2c9fe9..9d0953eb 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -213,7 +213,7 @@ private void ensureFileCapacity(ManagedConfiguration config, UUID protectedOpera } private ConfigurationTaskResult boundedResult(StoredOperation operation, ConfigurationTaskResult result) { - ManagedConfiguration configuration = result.configuration(); + ManagedConfiguration configuration = result.success() ? result.configuration() : null; if (configuration != null && ManagedConfiguration.FILE.equals(configuration.domain())) { boolean keepContent = result.success() && "READ".equals(operation.type) && operation.results.values().stream().filter(ConfigurationTaskResult::success) diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index ab2d3772..39336460 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -503,7 +503,7 @@ class ConfigurationOperationsTest { read = operations.complete(read.operationId(), "backend-b", new ConfigurationTaskResult(second, true, "OK", "read", "b".repeat(64), content, List.of(), false, false)); - assertEquals(null, read.results().get("backend-a").configuration().content()); + assertEquals(null, read.results().get("backend-a").configuration()); assertEquals("Feature: true\n", read.results().get("backend-b").configuration().content()); } From a9a5bcdf5bb5c1760a14675e7e0f885da284e118 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 26 Aug 2026 18:36:01 -0600 Subject: [PATCH 31/42] Bind hosted health to the launched process --- README.md | 1 + .../control/ControlApplication.java | 18 +++++++++-- .../control/http/ControlHttpServer.java | 32 +++++++++++++++---- .../control/ControlApplicationTest.java | 2 ++ .../control/http/ControlHttpServerTest.java | 7 ++-- 5 files changed, 49 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e88ff92d..5ccf2a3f 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ report `development` rather than maintaining a second version literal. | `CONTROL_REQUEST_TIMEOUT_SECONDS` | `10` | `1`–`60`; bounds stalled JDK HTTP exchanges | | `CONTROL_SECURE_COOKIE` | `false` | Set `true` only when browsers reach Control through a trusted HTTPS reverse proxy | | `CONTROL_TRUSTED_PROXY_ADDRESSES` | empty | Comma-separated IP literals for reverse proxies allowed to supply `X-Forwarded-For` login admission identity | +| `CONTROL_LAUNCH_ID` | empty | Optional UUID echoed by health checks so a supervising VotingPlugin can verify ownership of the listener | The server also uses a bounded HTTP executor (8 active requests and a 32-request queue), a 768 KiB request limit, bounded JSON depth/string/number sizes, a two-worker password-verification executor with per-client admission, and a bounded invalid-authentication failure limit. Configure the exact reverse-proxy IP addresses when HTTPS terminates upstream; forwarding headers from every other peer are ignored. Valid enrolled/admin credentials remain diff --git a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java index 96ee96de..2ccf4860 100644 --- a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java +++ b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java @@ -137,7 +137,8 @@ static void runServer(Map environment) throws Exception { ConfigurationOperations operations = new ConfigurationOperations(registry, new ConfigurationAuditLog(configuration.dataDirectory(), clock), clock); ControlHttpServer server = new ControlHttpServer(configuration.address(), registry, identity, credentials, - operations, configuration.secureCookies(), configuration.trustedProxyAddresses()); + operations, configuration.secureCookies(), configuration.trustedProxyAddresses(), + configuration.launchId()); CountDownLatch shutdown = new CountDownLatch(1); Runtime.getRuntime().addShutdownHook(new Thread(() -> { server.close(); @@ -204,7 +205,8 @@ private static Path requirePath(String value) { } record Configuration(InetSocketAddress address, Path dataDirectory, Duration offlineTimeout, - int requestTimeoutSeconds, boolean secureCookies, Set trustedProxyAddresses) { + int requestTimeoutSeconds, boolean secureCookies, Set trustedProxyAddresses, + String launchId) { static Configuration from(Map environment) throws IOException { String host = environment.getOrDefault("CONTROL_HOST", "127.0.0.1").trim(); if (host.isEmpty()) { @@ -220,9 +222,19 @@ static Configuration from(Map environment) throws IOException { "secure cookie"); Set trustedProxies = trustedProxyAddresses( environment.getOrDefault("CONTROL_TRUSTED_PROXY_ADDRESSES", "")); + String launchId = launchId(environment.getOrDefault("CONTROL_LAUNCH_ID", "")); return new Configuration(new InetSocketAddress(resolved, port), requirePath(environment.getOrDefault("CONTROL_DATA_DIR", "data")), - Duration.ofSeconds(offlineSeconds), requestSeconds, secureCookies, trustedProxies); + Duration.ofSeconds(offlineSeconds), requestSeconds, secureCookies, trustedProxies, launchId); + } + + private static String launchId(String value) { + if (value == null || value.isBlank()) return null; + try { + return UUID.fromString(value.trim()).toString(); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid launch ID"); + } } private static Set trustedProxyAddresses(String value) throws IOException { diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 389398f7..751ec14c 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -86,46 +86,61 @@ public final class ControlHttpServer implements AutoCloseable { private final WebSessionStore webSessions; private final boolean secureCookies; private final Set trustedProxyAddresses; + private final String launchId; public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials) throws IOException { + this(address, registry, identity, credentials, null); + } + + public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, + CredentialStore credentials, String launchId) throws IOException { this(address, registry, identity, credentials, new ConfigurationOperations(registry, new com.bencodez.votingplugin.control.domain.ConfigurationAuditLog( java.nio.file.Files.createTempDirectory("votingplugin-control-test-audit"), Clock.systemUTC()), - Clock.systemUTC()), Clock.systemUTC(), System::nanoTime, false, Set.of()); + Clock.systemUTC()), Clock.systemUTC(), System::nanoTime, false, Set.of(), launchId); } public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials, ConfigurationOperations configurationOperations) throws IOException { this(address, registry, identity, credentials, configurationOperations, Clock.systemUTC(), System::nanoTime, - false, Set.of()); + false, Set.of(), null); } public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials, ConfigurationOperations configurationOperations, boolean secureCookies) throws IOException { this(address, registry, identity, credentials, configurationOperations, Clock.systemUTC(), System::nanoTime, - secureCookies, Set.of()); + secureCookies, Set.of(), null); } public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials, ConfigurationOperations configurationOperations, boolean secureCookies, Set trustedProxyAddresses) throws IOException { this(address, registry, identity, credentials, configurationOperations, Clock.systemUTC(), System::nanoTime, - secureCookies, trustedProxyAddresses); + secureCookies, trustedProxyAddresses, null); + } + + public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, + CredentialStore credentials, ConfigurationOperations configurationOperations, + boolean secureCookies, Set trustedProxyAddresses, String launchId) + throws IOException { + this(address, registry, identity, credentials, configurationOperations, Clock.systemUTC(), System::nanoTime, + secureCookies, trustedProxyAddresses, launchId); } ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials, ConfigurationOperations configurationOperations, Clock clock, java.util.function.LongSupplier nanoTime, boolean secureCookies, - Set trustedProxyAddresses) throws IOException { + Set trustedProxyAddresses, String launchId) throws IOException { this.registry = Objects.requireNonNull(registry, "registry"); this.identity = Objects.requireNonNull(identity, "identity"); this.credentials = Objects.requireNonNull(credentials, "credentials"); this.configurationOperations = Objects.requireNonNull(configurationOperations, "configurationOperations"); this.secureCookies = secureCookies; this.trustedProxyAddresses = Set.copyOf(Objects.requireNonNull(trustedProxyAddresses, "trustedProxyAddresses")); + this.launchId = launchId; Objects.requireNonNull(clock, "clock"); json = new ObjectMapper(); json.findAndRegisterModules(); @@ -233,7 +248,12 @@ private void route(HttpExchange exchange) throws IOException { } if (HEALTH.equals(path)) { requireMethod(exchange, "GET"); - send(exchange, 200, Map.of("status", "ok", "time", Instant.now(), "identity", identity)); + Map health = new HashMap<>(); + health.put("status", "ok"); + health.put("time", Instant.now()); + health.put("identity", identity); + if (launchId != null) health.put("launchId", launchId); + send(exchange, 200, health); return; } if (AUTH_LOGIN.equals(path)) { diff --git a/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java index 504611f8..e6f2731a 100644 --- a/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java @@ -95,6 +95,8 @@ class ControlApplicationTest { assertThrows(IllegalArgumentException.class, () -> ControlApplication.Configuration.from(java.util.Map.of( "CONTROL_TRUSTED_PROXY_ADDRESSES", "proxy.local"))); + assertThrows(IllegalArgumentException.class, + () -> ControlApplication.Configuration.from(java.util.Map.of("CONTROL_LAUNCH_ID", "not-a-uuid"))); assertEquals(Set.of("127.0.0.1"), ControlApplication.Configuration.from(java.util.Map.of( "CONTROL_TRUSTED_PROXY_ADDRESSES", "127.0.0.1")).trustedProxyAddresses()); } diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 0bc3c274..60eb38b0 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -43,7 +43,7 @@ class ControlHttpServerTest { server = new ControlHttpServer(new InetSocketAddress("127.0.0.1", 0), new InMemoryNodeRegistry(Clock.systemUTC(), Duration.ofSeconds(90)), new ControlIdentity(UUID.fromString("00000000-0000-0000-0000-000000000099"), "test", 1), - credentials); + credentials, "00000000-0000-0000-0000-000000000123"); server.start(); base = URI.create("http://127.0.0.1:" + server.port()); client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build(); @@ -78,7 +78,10 @@ class ControlHttpServerTest { assertTrue(script.body().contains("result.success && result.configuration")); assertEquals(200, get("/app.css", null).statusCode()); assertError(send("POST", "/", null, null), 405, "METHOD_NOT_ALLOWED"); - assertEquals(200, get("/api/v1/health", null).statusCode()); + HttpResponse health = get("/api/v1/health", null); + assertEquals(200, health.statusCode()); + assertEquals("00000000-0000-0000-0000-000000000123", + json.readTree(health.body()).get("launchId").asText()); assertError(get("/api/v1/health/anything", null), 404, "NOT_FOUND"); assertError(get("/api/v1/nodes/register/anything", null), 404, "NOT_FOUND"); HttpResponse method = send("POST", "/api/v1/health", "{}", null); From 0fac3b196cd7264dca33b948620af716bd94d114 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 26 Aug 2026 18:37:49 -0600 Subject: [PATCH 32/42] Disambiguate optional health launch ID --- .../bencodez/votingplugin/control/http/ControlHttpServer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 751ec14c..42cd1ddc 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -90,7 +90,7 @@ public final class ControlHttpServer implements AutoCloseable { public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, CredentialStore credentials) throws IOException { - this(address, registry, identity, credentials, null); + this(address, registry, identity, credentials, (String) null); } public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, ControlIdentity identity, From aa5d8298a95e4033ac2d7cf8d68b6028ea0749ce Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 26 Aug 2026 20:21:04 -0600 Subject: [PATCH 33/42] Exit hosted Control with its parent --- README.md | 1 + .../control/ControlApplication.java | 34 +++++++++++++++++-- .../control/ControlApplicationTest.java | 4 +++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5ccf2a3f..30f8d955 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ report `development` rather than maintaining a second version literal. | `CONTROL_SECURE_COOKIE` | `false` | Set `true` only when browsers reach Control through a trusted HTTPS reverse proxy | | `CONTROL_TRUSTED_PROXY_ADDRESSES` | empty | Comma-separated IP literals for reverse proxies allowed to supply `X-Forwarded-For` login admission identity | | `CONTROL_LAUNCH_ID` | empty | Optional UUID echoed by health checks so a supervising VotingPlugin can verify ownership of the listener | +| `CONTROL_PARENT_PID` | empty | Optional supervising VotingPlugin process ID; hosted Control exits when that parent process ends | The server also uses a bounded HTTP executor (8 active requests and a 32-request queue), a 768 KiB request limit, bounded JSON depth/string/number sizes, a two-worker password-verification executor with per-client admission, and a bounded invalid-authentication failure limit. Configure the exact reverse-proxy IP addresses when HTTPS terminates upstream; forwarding headers from every other peer are ignored. Valid enrolled/admin credentials remain diff --git a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java index 2ccf4860..93ab47d3 100644 --- a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java +++ b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java @@ -139,12 +139,23 @@ static void runServer(Map environment) throws Exception { ControlHttpServer server = new ControlHttpServer(configuration.address(), registry, identity, credentials, operations, configuration.secureCookies(), configuration.trustedProxyAddresses(), configuration.launchId()); + ProcessHandle parent = parentProcess(configuration.parentPid()); CountDownLatch shutdown = new CountDownLatch(1); Runtime.getRuntime().addShutdownHook(new Thread(() -> { server.close(); shutdown.countDown(); }, "votingplugin-control-shutdown")); server.start(); + if (parent != null) { + parent.onExit().thenRun(() -> { + server.close(); + shutdown.countDown(); + }); + if (!parent.isAlive()) { + server.close(); + shutdown.countDown(); + } + } System.out.printf("VotingPlugin Control %s listening on http://%s:%d (protocol v%d)%n", VersionInfo.applicationVersion(), configuration.address().getHostString(), server.port(), Protocol.VERSION); @@ -204,9 +215,15 @@ private static Path requirePath(String value) { return Path.of(value); } + private static ProcessHandle parentProcess(Long parentPid) { + if (parentPid == null) return null; + return ProcessHandle.of(parentPid).filter(ProcessHandle::isAlive) + .orElseThrow(() -> new IllegalArgumentException("Hosted parent process is unavailable")); + } + record Configuration(InetSocketAddress address, Path dataDirectory, Duration offlineTimeout, int requestTimeoutSeconds, boolean secureCookies, Set trustedProxyAddresses, - String launchId) { + String launchId, Long parentPid) { static Configuration from(Map environment) throws IOException { String host = environment.getOrDefault("CONTROL_HOST", "127.0.0.1").trim(); if (host.isEmpty()) { @@ -223,9 +240,11 @@ static Configuration from(Map environment) throws IOException { Set trustedProxies = trustedProxyAddresses( environment.getOrDefault("CONTROL_TRUSTED_PROXY_ADDRESSES", "")); String launchId = launchId(environment.getOrDefault("CONTROL_LAUNCH_ID", "")); + Long parentPid = parentPid(environment.getOrDefault("CONTROL_PARENT_PID", "")); return new Configuration(new InetSocketAddress(resolved, port), requirePath(environment.getOrDefault("CONTROL_DATA_DIR", "data")), - Duration.ofSeconds(offlineSeconds), requestSeconds, secureCookies, trustedProxies, launchId); + Duration.ofSeconds(offlineSeconds), requestSeconds, secureCookies, trustedProxies, launchId, + parentPid); } private static String launchId(String value) { @@ -237,6 +256,17 @@ private static String launchId(String value) { } } + private static Long parentPid(String value) { + if (value == null || value.isBlank()) return null; + try { + long parsed = Long.parseLong(value.trim()); + if (parsed < 1L) throw new NumberFormatException(); + return parsed; + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid hosted parent PID"); + } + } + private static Set trustedProxyAddresses(String value) throws IOException { if (value == null || value.isBlank()) return Set.of(); Set result = new LinkedHashSet<>(); diff --git a/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java index e6f2731a..2dfc10ba 100644 --- a/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java @@ -97,6 +97,10 @@ class ControlApplicationTest { "CONTROL_TRUSTED_PROXY_ADDRESSES", "proxy.local"))); assertThrows(IllegalArgumentException.class, () -> ControlApplication.Configuration.from(java.util.Map.of("CONTROL_LAUNCH_ID", "not-a-uuid"))); + assertThrows(IllegalArgumentException.class, + () -> ControlApplication.Configuration.from(java.util.Map.of("CONTROL_PARENT_PID", "0"))); + assertEquals(123L, ControlApplication.Configuration.from( + java.util.Map.of("CONTROL_PARENT_PID", "123")).parentPid()); assertEquals(Set.of("127.0.0.1"), ControlApplication.Configuration.from(java.util.Map.of( "CONTROL_TRUSTED_PROXY_ADDRESSES", "127.0.0.1")).trustedProxyAddresses()); } From 5e7f05a7f6a9e0f477e8a9d5da58f23ba5a0947b Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 27 Aug 2026 19:59:35 -0600 Subject: [PATCH 34/42] Accept worst-case encoded configuration results --- README.md | 2 +- .../bencodez/votingplugin/control/http/ControlHttpServer.java | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 30f8d955..3b4af776 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ report `development` rather than maintaining a second version literal. | `CONTROL_LAUNCH_ID` | empty | Optional UUID echoed by health checks so a supervising VotingPlugin can verify ownership of the listener | | `CONTROL_PARENT_PID` | empty | Optional supervising VotingPlugin process ID; hosted Control exits when that parent process ends | -The server also uses a bounded HTTP executor (8 active requests and a 32-request queue), a 768 KiB request limit, bounded +The server also uses a bounded HTTP executor (8 active requests and a 32-request queue), a 4 MiB request limit, bounded JSON depth/string/number sizes, a two-worker password-verification executor with per-client admission, and a bounded invalid-authentication failure limit. Configure the exact reverse-proxy IP addresses when HTTPS terminates upstream; forwarding headers from every other peer are ignored. Valid enrolled/admin credentials remain usable even while invalid traffic is throttled. Shutdown stops the server and its daemon request workers without waiting indefinitely. diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 42cd1ddc..022a8f18 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -53,7 +53,8 @@ /** Bounded HTTP adapter. Node writes are enrolled; node listings require the local admin credential. */ public final class ControlHttpServer implements AutoCloseable { - public static final int MAX_REQUEST_BYTES = 768 * 1024; + // Covers the worst-case JSON encoding of the 512 KiB managed-configuration limit plus its result envelope. + public static final int MAX_REQUEST_BYTES = 4 * 1024 * 1024; private static final String HEALTH = "/api/v1/health"; private static final String NODES = "/api/v1/nodes"; private static final String REGISTER = "/api/v1/nodes/register"; From c4da8db815e5b396fc3d055a2abd33372ce7b4aa Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 27 Aug 2026 20:24:06 -0600 Subject: [PATCH 35/42] Fence configuration task attempts and UI state --- README.md | 3 + .../domain/ConfigurationOperations.java | 23 ++++-- .../control/http/ControlHttpServer.java | 3 +- .../control/protocol/ConfigurationTask.java | 2 +- .../protocol/ConfigurationTaskResult.java | 7 +- src/main/resources/web/app.js | 12 ++- .../domain/ConfigurationOperationsTest.java | 75 +++++++++++++------ .../control/http/ControlHttpServerTest.java | 5 +- 8 files changed, 92 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 3b4af776..619b78ef 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,9 @@ JSON depth/string/number sizes, a two-worker password-verification executor with usable even while invalid traffic is throttled. Shutdown stops the server and its daemon request workers without waiting indefinitely. +Claimed configuration tasks include an `attemptId`. Nodes must echo it in the result so an expired and reissued lease +cannot be completed by a stale execution from the same node session. + Set the WebUI password from an interactive console, then open `http://127.0.0.1:8080/`: ```shell diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 9d0953eb..09a90e4e 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -120,22 +120,28 @@ private ConfigurationTask claimCurrentSession(String nodeId, NodeStatus node) { audit.append("TASK_CANCELLED", operation.id, nodeId, "CAPABILITY_LOST"); operation.results.put(nodeId, boundedResult(operation, new ConfigurationTaskResult(sessionId(node), false, "CAPABILITY_LOST", "Node no longer accepts this configuration capability", null, - (ManagedConfiguration) null, List.of(), false, false))); + (ManagedConfiguration) null, List.of(), false, false, null))); operation.states.put(nodeId, "COMPLETE"); operation.leasedAt.remove(nodeId); + operation.attemptIds.remove(nodeId); continue; } + UUID previousAttempt = operation.attemptIds.get(nodeId); + UUID attemptId = UUID.randomUUID(); operation.states.put(nodeId, "IN_PROGRESS"); operation.leasedAt.put(nodeId, now); + operation.attemptIds.put(nodeId, attemptId); try { audit.append("TASK_CLAIMED", operation.id, nodeId, operation.type); } catch (RuntimeException e) { operation.states.put(nodeId, state); if (leased == null) operation.leasedAt.remove(nodeId); else operation.leasedAt.put(nodeId, leased); + if (previousAttempt == null) operation.attemptIds.remove(nodeId); + else operation.attemptIds.put(nodeId, previousAttempt); throw e; } return new ConfigurationTask(operation.id, operation.type, operation.configuration, - operation.expectedRevisions.get(nodeId)); + operation.expectedRevisions.get(nodeId), attemptId); } } return null; @@ -160,11 +166,15 @@ private OperationView completeCurrentSession(UUID operationId, String nodeId, Co if (!"IN_PROGRESS".equals(operation.states.get(nodeId))) { throw new ValidationException("TASK_NOT_CLAIMED", "Operation task must be claimed before completion", List.of()); } + if (!Objects.equals(operation.attemptIds.get(nodeId), result.attemptId())) { + throw new ValidationException("TASK_LEASE_EXPIRED", "Operation task lease is no longer active", List.of()); + } validateResultConfiguration(operation, result); audit.append("TASK_COMPLETED", operation.id, nodeId, result.success() ? "SUCCESS" : result.code()); operation.results.put(nodeId, boundedResult(operation, result)); operation.states.put(nodeId, "COMPLETE"); operation.leasedAt.remove(nodeId); + operation.attemptIds.remove(nodeId); return view(operation); } @@ -189,7 +199,7 @@ private StoredOperation store(String type, List targets, ManagedConfigur LinkedHashMap states = new LinkedHashMap<>(); targets.forEach(node -> states.put(node, "QUEUED")); StoredOperation result = new StoredOperation(id, type, config, token, clock.instant(), states, - new LinkedHashMap<>(), new LinkedHashMap<>(), new LinkedHashMap<>(revisions)); + new LinkedHashMap<>(), new LinkedHashMap<>(), new LinkedHashMap<>(), new LinkedHashMap<>(revisions)); operations.put(id, result); return result; } @@ -227,7 +237,7 @@ private ConfigurationTaskResult boundedResult(StoredOperation operation, Configu } List changes = retainChanges(result.changes()); return new ConfigurationTaskResult(result.sessionId(), result.success(), result.code(), retainMessage(result.message()), - result.revision(), configuration, changes, result.reloaded(), result.rolledBack()); + result.revision(), configuration, changes, result.reloaded(), result.rolledBack(), result.attemptId()); } private static void validateResultConfiguration(StoredOperation operation, ConfigurationTaskResult result) { @@ -344,7 +354,7 @@ private static ValidationException invalid(String detail) { } private static void validateResult(ConfigurationTaskResult result) { - if (result == null || result.sessionId() == null || result.code() == null + if (result == null || result.sessionId() == null || result.attemptId() == null || result.code() == null || !result.code().matches("[A-Z][A-Z0-9_]{0,63}") || result.message() == null || result.message().isBlank() || result.message().length() > 500 || result.changes().size() > 20 || result.changes().stream().anyMatch(value -> value == null || value.length() > 500) @@ -375,6 +385,7 @@ private static final class StoredOperation { private final LinkedHashMap states; private final LinkedHashMap results; private final LinkedHashMap leasedAt; + private final LinkedHashMap attemptIds; private final LinkedHashMap expectedRevisions; private boolean approvalUsed; @@ -382,9 +393,11 @@ private StoredOperation(UUID id, String type, ManagedConfiguration configuration Instant createdAt, LinkedHashMap states, LinkedHashMap results, LinkedHashMap leasedAt, + LinkedHashMap attemptIds, LinkedHashMap expectedRevisions) { this.id = id; this.type = type; this.configuration = configuration; this.approvalToken = approvalToken; this.createdAt = createdAt; this.states = states; this.results = results; this.leasedAt = leasedAt; + this.attemptIds = attemptIds; this.expectedRevisions = expectedRevisions; } private boolean complete() { return states.values().stream().allMatch("COMPLETE"::equals); } diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 022a8f18..b95a5b79 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -216,8 +216,7 @@ private void handle(HttpExchange exchange) throws IOException { case "NODE_NOT_FOUND", "OPERATION_NOT_FOUND" -> 404; case "UNSUPPORTED_PROTOCOL", "INCOMPATIBLE_CAPABILITIES", "SESSION_MISMATCH", "PREVIEW_INCOMPLETE", "APPROVAL_REQUIRED", "NODE_UNAVAILABLE", "OPERATION_LIMIT", - "REGISTRY_LIMIT" -> 409; - case "TASK_NOT_CLAIMED" -> 409; + "REGISTRY_LIMIT", "TASK_NOT_CLAIMED", "TASK_LEASE_EXPIRED" -> 409; case "UNSUPPORTED_MEDIA_TYPE" -> 415; default -> 400; }; diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTask.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTask.java index 9b0d8275..ba3b8e67 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTask.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTask.java @@ -3,4 +3,4 @@ import java.util.UUID; public record ConfigurationTask(UUID operationId, String type, ManagedConfiguration configuration, - String expectedRevision) { } + String expectedRevision, UUID attemptId) { } diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTaskResult.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTaskResult.java index 517c5811..314ecec4 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTaskResult.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTaskResult.java @@ -5,15 +5,16 @@ public record ConfigurationTaskResult(UUID sessionId, boolean success, String code, String message, String revision, ManagedConfiguration configuration, List changes, - boolean reloaded, boolean rolledBack) { + boolean reloaded, boolean rolledBack, UUID attemptId) { public ConfigurationTaskResult { changes = changes == null ? List.of() : List.copyOf(changes); } public ConfigurationTaskResult(UUID sessionId, boolean success, String code, String message, String revision, ProxyRoutingConfiguration configuration, List changes, - boolean reloaded, boolean rolledBack) { + boolean reloaded, boolean rolledBack, UUID attemptId) { this(sessionId, success, code, message, revision, - configuration == null ? null : ManagedConfiguration.proxy(configuration), changes, reloaded, rolledBack); + configuration == null ? null : ManagedConfiguration.proxy(configuration), changes, reloaded, rolledBack, + attemptId); } } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 580967b1..de6e8408 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -60,6 +60,7 @@ let nodePlugins = new Map(); let inputGeneration = 0; let authenticationGeneration = 0; let loginInFlight = false; +let configurationOperationsInFlight = 0; function text(element, value) { element.textContent = value; @@ -127,7 +128,7 @@ function nodeCard(node) { return article; } -function updateConfigurationButtons(busy = false) { +function updateConfigurationButtons(busy = configurationOperationsInFlight > 0) { const routingReady = authenticated && targets('config.proxy-routing.v1').length > 0 && !busy; const fileReady = authenticated && targets('config.files.v1').length > 0 && !busy; const quickReady = authenticated && targets('config.quick-setup.v1').length > 0 && !busy; @@ -255,13 +256,15 @@ async function waitForOperation(operation, statusElement = operationStatus) { } async function startConfigurationOperation(path, body, statusElement = operationStatus) { - updateConfigurationButtons(true); + configurationOperationsInFlight++; + updateConfigurationButtons(); try { return await waitForOperation(await authorized(path, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) }), statusElement); } finally { - updateConfigurationButtons(false); + configurationOperationsInFlight--; + updateConfigurationButtons(); } } @@ -419,6 +422,9 @@ readConfiguration.addEventListener('click', async () => { && readInputGeneration === inputGeneration) { sendAll.checked = retained.configuration.sendVotesToAllServers; blockedServers.value = retained.configuration.blockedServers.join('\n'); + approvedPreview = null; + inputGeneration++; + updateConfigurationButtons(); } } catch (error) { text(operationStatus, error.message); } }); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 39336460..c348d705 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -45,7 +45,7 @@ class ConfigurationOperationsTest { assertEquals("PREVIEW", previewTask.type()); preview = operations.complete(preview.operationId(), "proxy-a", new ConfigurationTaskResult(registry.find("proxy-a").sessionId(), true, "OK", "valid", "a".repeat(64), proposal, - List.of("blockedServers changed"), false, false)); + List.of("blockedServers changed"), false, false, previewTask.attemptId())); assertEquals("SUCCEEDED", preview.state()); assertNotNull(preview.approvalToken()); @@ -102,11 +102,11 @@ class ConfigurationOperationsTest { registry.register(registration(second)); assertEquals("SESSION_MISMATCH", assertThrows(ValidationException.class, () -> operations.claim("proxy-a", first)).code()); - operations.claim("proxy-a", second); + ConfigurationTask task = operations.claim("proxy-a", second); registry.register(registration(third)); ProxyRoutingConfiguration configuration = new ProxyRoutingConfiguration(false, List.of()); ConfigurationTaskResult stale = new ConfigurationTaskResult(second, true, "OK", "read", "b".repeat(64), - configuration, List.of(), false, false); + configuration, List.of(), false, false, task.attemptId()); assertEquals("SESSION_MISMATCH", assertThrows(ValidationException.class, () -> operations.complete(operation.operationId(), "proxy-a", stale)).code()); } @@ -289,10 +289,10 @@ class ConfigurationOperationsTest { new ConfigurationAuditLog(directory, clock), clock); ProxyRoutingConfiguration proposal = new ProxyRoutingConfiguration(true, List.of()); ConfigurationOperations.OperationView preview = operations.createPreview(List.of("proxy-a"), proposal); - operations.claim("proxy-a", registry.find("proxy-a").sessionId()); + ConfigurationTask task = operations.claim("proxy-a", registry.find("proxy-a").sessionId()); preview = operations.complete(preview.operationId(), "proxy-a", new ConfigurationTaskResult(registry.find("proxy-a").sessionId(), true, "OK", "valid", - "c".repeat(64), proposal, List.of(), false, false)); + "c".repeat(64), proposal, List.of(), false, false, task.attemptId())); for (int i = 0; i < 999; i++) operations.createRead(List.of("proxy-a")); UUID previewId = preview.operationId(); @@ -322,6 +322,30 @@ class ConfigurationOperationsTest { () -> operations.get(operation)).code()); } + @Test void expiredLeaseResultCannotCompleteAReissuedAttemptForTheSameSession() throws Exception { + MutableClock clock = new MutableClock(Instant.parse("2026-08-25T00:00:00Z")); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofHours(1)); + UUID session = UUID.randomUUID(); + registry.register(registration(session)); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + UUID operation = operations.createRead(List.of("proxy-a")).operationId(); + ConfigurationTask first = operations.claim("proxy-a", session); + + clock.advance(Duration.ofMinutes(2)); + ConfigurationTask second = operations.claim("proxy-a", session); + assertTrue(!first.attemptId().equals(second.attemptId())); + ProxyRoutingConfiguration configuration = new ProxyRoutingConfiguration(false, List.of()); + ConfigurationTaskResult stale = new ConfigurationTaskResult(session, true, "OK", "read", "a".repeat(64), + configuration, List.of(), false, false, first.attemptId()); + assertEquals("TASK_LEASE_EXPIRED", assertThrows(ValidationException.class, + () -> operations.complete(operation, "proxy-a", stale)).code()); + + ConfigurationTaskResult current = new ConfigurationTaskResult(session, true, "OK", "read", "a".repeat(64), + configuration, List.of(), false, false, second.attemptId()); + assertEquals("SUCCEEDED", operations.complete(operation, "proxy-a", current).state()); + } + @Test void failedTaskResultCannotRetainAnUnboundedRevision() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofHours(1)); @@ -330,11 +354,11 @@ class ConfigurationOperationsTest { ConfigurationOperations operations = new ConfigurationOperations(registry, new ConfigurationAuditLog(directory, clock), clock); UUID operation = operations.createRead(List.of("proxy-a")).operationId(); - operations.claim("proxy-a", session); + ConfigurationTask task = operations.claim("proxy-a", session); assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class, () -> operations.complete(operation, "proxy-a", new ConfigurationTaskResult(session, false, "FAILED", "failed", "x".repeat(1000), - (ManagedConfiguration) null, List.of(), false, false))).code()); + (ManagedConfiguration) null, List.of(), false, false, task.attemptId()))).code()); } @Test void taskResultConfigurationMustMatchItsOperationDomainAndSelector() throws Exception { @@ -345,11 +369,12 @@ class ConfigurationOperationsTest { ConfigurationOperations operations = new ConfigurationOperations(registry, new ConfigurationAuditLog(directory, clock), clock); UUID operation = operations.createRead(List.of("proxy-a")).operationId(); - operations.claim("proxy-a", session); + ConfigurationTask task = operations.claim("proxy-a", session); assertEquals("VALIDATION_ERROR", assertThrows(ValidationException.class, () -> operations.complete(operation, "proxy-a", new ConfigurationTaskResult(session, true, "OK", "read", "a".repeat(64), - ManagedConfiguration.file("Config.yml", "large: value\n"), List.of(), false, false))).code()); + ManagedConfiguration.file("Config.yml", "large: value\n"), List.of(), false, false, + task.attemptId()))).code()); } @Test void managedConfigurationRejectsFieldsFromAnotherUnionDomain() { @@ -374,10 +399,12 @@ class ConfigurationOperationsTest { ManagedConfiguration file = ManagedConfiguration.file("Config.yml", "Feature: true\n"); ConfigurationOperations.OperationView preview = operations.createPreview(List.of("backend-a"), file); assertEquals(null, preview.configuration().content()); - assertEquals("file", operations.claim("backend-a", session).configuration().domain()); + ConfigurationTask task = operations.claim("backend-a", session); + assertEquals("file", task.configuration().domain()); ManagedConfiguration current = ManagedConfiguration.file("Config.yml", "Feature: false\n"); preview = operations.complete(preview.operationId(), "backend-a", new ConfigurationTaskResult(session, true, - "OK", "valid", "d".repeat(64), current, List.of("changed Feature"), false, false)); + "OK", "valid", "d".repeat(64), current, List.of("changed Feature"), false, false, + task.attemptId())); assertNotNull(preview.approvalToken()); assertEquals("NODE_UNAVAILABLE", assertThrows(ValidationException.class, @@ -418,13 +445,13 @@ class ConfigurationOperationsTest { new ConfigurationAuditLog(directory, clock), clock); ManagedConfiguration selector = ManagedConfiguration.file("Config.yml", null); ConfigurationOperations.OperationView read = operations.createRead(List.of("backend-a", "backend-b"), selector); - operations.claim("backend-a", first); - operations.claim("backend-b", second); + ConfigurationTask firstTask = operations.claim("backend-a", first); + ConfigurationTask secondTask = operations.claim("backend-b", second); ManagedConfiguration content = ManagedConfiguration.file("Config.yml", "Feature: true\n"); operations.complete(read.operationId(), "backend-a", new ConfigurationTaskResult(first, true, "OK", "read", - "e".repeat(64), content, List.of(), false, false)); + "e".repeat(64), content, List.of(), false, false, firstTask.attemptId())); read = operations.complete(read.operationId(), "backend-b", new ConfigurationTaskResult(second, true, "OK", - "read", "f".repeat(64), content, List.of(), false, false)); + "read", "f".repeat(64), content, List.of(), false, false, secondTask.attemptId())); assertNotNull(read.results().get("backend-a").configuration().content()); assertEquals(null, read.results().get("backend-b").configuration().content()); @@ -453,9 +480,10 @@ class ConfigurationOperationsTest { List maximumChanges = java.util.stream.IntStream.range(0, 20) .mapToObj(ignored -> "x".repeat(500)).toList(); for (String node : nodes) { - operations.claim(node, sessions.get(node)); + ConfigurationTask task = operations.claim(node, sessions.get(node)); read = operations.complete(read.operationId(), node, new ConfigurationTaskResult(sessions.get(node), - true, "OK", "read", "a".repeat(64), configuration, maximumChanges, false, false)); + true, "OK", "read", "a".repeat(64), configuration, maximumChanges, false, false, + task.attemptId())); } long retainedBytes = read.results().values().stream().flatMap(result -> result.changes().stream()) @@ -469,9 +497,10 @@ class ConfigurationOperationsTest { ConfigurationOperations.OperationView messages = operations.createRead(nodes); messageOperations.add(messages.operationId()); for (String node : nodes) { - operations.claim(node, sessions.get(node)); + ConfigurationTask task = operations.claim(node, sessions.get(node)); operations.complete(messages.operationId(), node, new ConfigurationTaskResult(sessions.get(node), - true, "OK", "m".repeat(500), "b".repeat(64), configuration, List.of(), false, false)); + true, "OK", "m".repeat(500), "b".repeat(64), configuration, List.of(), false, false, + task.attemptId())); } } long retainedMessages = messageOperations.stream().map(operations::get) @@ -496,12 +525,12 @@ class ConfigurationOperationsTest { ConfigurationOperations.OperationView read = operations.createRead(List.of("backend-a", "backend-b"), selector); ManagedConfiguration content = ManagedConfiguration.file("Config.yml", "Feature: true\n"); - operations.claim("backend-a", first); + ConfigurationTask firstTask = operations.claim("backend-a", first); operations.complete(read.operationId(), "backend-a", new ConfigurationTaskResult(first, false, "READ_FAILED", - "failed", "a".repeat(64), content, List.of(), false, false)); - operations.claim("backend-b", second); + "failed", "a".repeat(64), content, List.of(), false, false, firstTask.attemptId())); + ConfigurationTask secondTask = operations.claim("backend-b", second); read = operations.complete(read.operationId(), "backend-b", new ConfigurationTaskResult(second, true, "OK", - "read", "b".repeat(64), content, List.of(), false, false)); + "read", "b".repeat(64), content, List.of(), false, false, secondTask.attemptId())); assertEquals(null, read.results().get("backend-a").configuration()); assertEquals("Feature: true\n", read.results().get("backend-b").configuration().content()); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 60eb38b0..0753427c 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -72,6 +72,8 @@ class ControlHttpServerTest { assertTrue(web.body().contains("Easy vote reward")); assertTrue(script.body().contains("filteredSelection.size !== selectedNodes.size")); assertTrue(script.body().contains("previewGeneration === inputGeneration")); + assertTrue(script.body().contains("configurationOperationsInFlight")); + assertTrue(script.body().contains("approvedPreview = null;\n inputGeneration++;")); assertTrue(script.body().contains("approvedPreview.nodeIds.every")); assertTrue(script.body().contains("selectedCapabilitiesChanged")); assertTrue(script.body().contains("discardAuthenticationState")); @@ -143,7 +145,8 @@ class ControlHttpServerTest { assertEquals("PREVIEW", previewTask.get("type").asText()); String previewResult = "{\"sessionId\":\"" + SESSION + "\",\"success\":true,\"code\":\"OK\","+ "\"message\":\"valid\",\"revision\":\"" + "a".repeat(64) + "\",\"configuration\":" - + proposal + ",\"changes\":[\"blockedServers changed\"],\"reloaded\":false,\"rolledBack\":false}"; + + proposal + ",\"changes\":[\"blockedServers changed\"],\"reloaded\":false,\"rolledBack\":false," + + "\"attemptId\":\"" + previewTask.get("attemptId").asText() + "\"}"; assertEquals(200, send("POST", "/api/v1/nodes/proxy-a/operations/" + previewId + "/result", previewResult, nodeToken).statusCode()); From 3ed0e838e65ff675a929e10061b3bc4d3938566f Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 27 Aug 2026 20:42:00 -0600 Subject: [PATCH 36/42] Bound stale registry and identity state --- .../control/ControlApplication.java | 19 ++++++++++++++++++- .../control/domain/InMemoryNodeRegistry.java | 3 +-- .../control/ControlApplicationTest.java | 11 +++++++++++ .../domain/InMemoryNodeRegistryTest.java | 10 ++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java index 93ab47d3..ea420547 100644 --- a/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java +++ b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java @@ -11,10 +11,13 @@ import java.io.PrintStream; import java.net.InetAddress; import java.net.InetSocketAddress; +import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; +import java.nio.channels.SeekableByteChannel; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; @@ -28,6 +31,7 @@ public final class ControlApplication { private static final Object IDENTITY_LOCK = new Object(); + private static final int MAX_IDENTITY_BYTES = 64; private ControlApplication() { } @@ -202,7 +206,20 @@ static UUID loadIdentity(Path directory) throws IOException { private static UUID readIdentity(Path file) throws IOException { try { - return UUID.fromString(Files.readString(file).trim()); + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file) + || Files.size(file) > MAX_IDENTITY_BYTES) { + throw new IOException("Control identity is invalid"); + } + ByteBuffer buffer = ByteBuffer.allocate(MAX_IDENTITY_BYTES + 1); + try (SeekableByteChannel channel = Files.newByteChannel(file, + Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) { + while (channel.read(buffer) >= 0 && buffer.hasRemaining()) { } + } + if (!buffer.hasRemaining()) throw new IOException("Control identity is invalid"); + buffer.flip(); + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + return UUID.fromString(new String(bytes, StandardCharsets.UTF_8).trim()); } catch (IllegalArgumentException e) { throw new IOException("Control identity is invalid"); } diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java index 54fd6292..4c8b76ef 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -214,8 +214,7 @@ private void reclaimOfflineTopology(Instant now, String excludedNodeId) { for (String candidate : List.copyOf(nodes.keySet())) { if (candidate.equals(excludedNodeId)) continue; nodes.computeIfPresent(candidate, (ignored, existing) -> { - if (now.isBefore(existing.lastSeen.plus(offlineTimeout)) - || (existing.detectedPlugins.isEmpty() && existing.backends.isEmpty())) return existing; + if (now.isBefore(existing.lastSeen.plus(offlineTimeout))) return existing; retainedDetectedPlugins -= existing.detectedPlugins.size(); retainedBackends -= existing.backends.size(); return null; diff --git a/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java index 2dfc10ba..66a3b1bc 100644 --- a/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java @@ -54,6 +54,17 @@ class ControlApplicationTest { assertThrows(java.io.IOException.class, () -> ControlApplication.loadIdentity(directory)); } + @Test void oversizedAndNonRegularIdentitiesFailClosedBeforeReading() throws Exception { + Path oversized = directory.resolve("oversized"); + Files.createDirectories(oversized); + Files.writeString(oversized.resolve("instance-id"), "x".repeat(65)); + assertThrows(java.io.IOException.class, () -> ControlApplication.loadIdentity(oversized)); + + Path nonRegular = directory.resolve("non-regular"); + Files.createDirectories(nonRegular.resolve("instance-id")); + assertThrows(java.io.IOException.class, () -> ControlApplication.loadIdentity(nonRegular)); + } + @Test void directoryDurabilityRecognizesWindowsAsUnsupported() { assertFalse(DurableFiles.supportsDirectoryChannels("Windows Server 2025")); assertTrue(DurableFiles.supportsDirectoryChannels("Linux")); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java index 047758bc..3ffddacb 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java @@ -119,6 +119,16 @@ class InMemoryNodeRegistryTest { assertEquals(Set.of("Four"), bounded.find("proxy-b").detectedPlugins()); } + @Test void staleEmptyRegistrationIsReclaimedWhenAnotherNodeRegisters() { + registry.register(registration("proxy-a", session, Set.of(), Set.of())); + + clock.advance(Duration.ofSeconds(90)); + registry.register(registration("proxy-b", UUID.randomUUID(), Set.of(), Set.of())); + + assertNull(registry.find("proxy-a")); + assertNotNull(registry.find("proxy-b")); + } + @Test void duplicateRegistrationIsAtomicUnderConcurrency() throws Exception { int count = 32; CountDownLatch ready = new CountDownLatch(count); From ee229ffde47c1e9c8bbe70f6fe066f1df54406ac Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 27 Aug 2026 20:49:27 -0600 Subject: [PATCH 37/42] Bound credential store reads --- .../control/auth/CredentialStore.java | 23 ++++++++++++++++--- .../control/auth/CredentialStoreTest.java | 8 +++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java b/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java index 7b7e7bfc..9c7c6192 100644 --- a/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java +++ b/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java @@ -4,10 +4,13 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; +import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; +import java.nio.channels.SeekableByteChannel; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; @@ -20,6 +23,7 @@ import java.util.HexFormat; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.regex.Pattern; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.PBEKeySpec; @@ -28,6 +32,7 @@ public final class CredentialStore { private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final int MAX_ENROLLMENTS = 10_000; + private static final int MAX_STORE_BYTES = 2 * 1024 * 1024; private static final int WEB_PASSWORD_ITERATIONS = 600_000; private static final int WEB_PASSWORD_BYTES = 32; private static final int WEB_PASSWORD_MINIMUM = 12; @@ -200,13 +205,25 @@ private boolean constantTimeMatches(String expectedHex, String token) { } private synchronized StoreData read() throws IOException { - if (!Files.exists(file)) { + if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) { return new StoreData(); } - byte[] bytes = Files.readAllBytes(file); - if (bytes.length > 2 * 1024 * 1024) { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(file)) { + throw new IOException("Credential store is unsafe"); + } + long checkedSize = Files.size(file); + if (checkedSize > MAX_STORE_BYTES) { throw new IOException("Credential store is too large"); } + ByteBuffer buffer = ByteBuffer.allocate((int) checkedSize + 1); + try (SeekableByteChannel channel = Files.newByteChannel(file, + Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) { + while (channel.read(buffer) >= 0 && buffer.hasRemaining()) { } + } + if (!buffer.hasRemaining()) throw new IOException("Credential store changed while it was read"); + buffer.flip(); + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes); try { StoreData data = json.readValue(bytes, StoreData.class); if (data == null) { diff --git a/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java b/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java index 7650ae52..0c1bff81 100644 --- a/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java @@ -55,4 +55,12 @@ class CredentialStoreTest { assertTrue(store.verifyWebPassword("a-different-long-password")); assertThrows(IllegalArgumentException.class, () -> store.setWebPassword("too-short".toCharArray())); } + + @Test void oversizedCredentialStoreFailsBeforeReadingItsContents() throws Exception { + Files.write(directory.resolve("credentials.json"), new byte[2 * 1024 * 1024 + 1]); + CredentialStore store = new CredentialStore(directory); + + assertThrows(java.io.IOException.class, store::hasAdmin); + assertFalse(store.verifyAdmin("vpctl_admin_invalid")); + } } From 3c7888711ff12eed56650d4c6bc3faa5a538fc0a Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 27 Aug 2026 21:00:55 -0600 Subject: [PATCH 38/42] Harden audit recovery and session teardown --- .../control/domain/ConfigurationAuditLog.java | 20 +++++++++++++++--- src/main/resources/web/app.js | 10 +++++++-- .../domain/ConfigurationOperationsTest.java | 21 +++++++++++++++++++ .../control/http/ControlHttpServerTest.java | 4 ++++ 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java index 13b18902..afae97b2 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.JsonNode; import java.io.IOException; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -12,11 +13,13 @@ import java.nio.file.StandardCopyOption; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; +import java.nio.channels.SeekableByteChannel; import java.security.MessageDigest; import java.time.Clock; import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; import java.util.UUID; /** Local append-only, hash-chained operation metadata. Values and approval tokens are never recorded. */ @@ -175,11 +178,22 @@ && matches(retained, pending.postRetainedHash(), pending.postRetainedRecords())) private SegmentState repairTornActive(PendingAppend pending, SegmentState retained, IOException invalidActive) throws IOException { if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw invalidActive; - byte[] actual = Files.readAllBytes(file); byte[] intended = pending.line().getBytes(StandardCharsets.UTF_8); long baseLong = pending.rotate() ? 0 : pending.preActiveBytes(); - if (baseLong > Integer.MAX_VALUE || actual.length <= baseLong - || actual.length >= baseLong + intended.length) throw invalidActive; + long maximumRepairBytes = Math.min(maxBytes, MAX_BYTES) + 64 * 1024; + if (baseLong > Integer.MAX_VALUE - intended.length + || baseLong + intended.length > maximumRepairBytes) throw invalidActive; + int exclusiveLimit = (int) baseLong + intended.length; + ByteBuffer buffer = ByteBuffer.allocate(exclusiveLimit); + try (SeekableByteChannel channel = Files.newByteChannel(file, + Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) { + while (channel.read(buffer) >= 0 && buffer.hasRemaining()) { } + } + if (!buffer.hasRemaining()) throw invalidActive; + buffer.flip(); + byte[] actual = new byte[buffer.remaining()]; + buffer.get(actual); + if (actual.length <= baseLong) throw invalidActive; int base = (int) baseLong; if (pending.rotate()) { if (!matches(retained, pending.postRetainedHash(), pending.postRetainedRecords())) throw invalidActive; diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index de6e8408..a3cedf1e 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -220,6 +220,9 @@ function discardAuthenticationState(reason) { nodePlugins.clear(); configurationContent.value = ''; blockedServers.value = ''; + text(operationStatus, ''); + text(fileOperationStatus, ''); + text(quickOperationStatus, ''); nodes.replaceChildren(); nodes.classList.add('empty'); text(nodes, 'Authenticate to view the network.'); @@ -388,9 +391,12 @@ logout.addEventListener('click', async () => { logout.disabled = true; try { await authorized('/api/v1/auth/logout', {method: 'POST'}); - } catch (_) { /* Expired is logged out too. */ } - finally { if (logoutGeneration === authenticationGeneration) discardAuthenticationState('Signed out.'); + } catch (_) { + if (logoutGeneration === authenticationGeneration) { + text(message, 'Sign out could not be confirmed. Check your connection and try again.'); + } + } finally { loginInFlight = false; loginButton.disabled = false; logout.disabled = false; diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index c348d705..82aa5e6b 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -191,6 +191,27 @@ class ConfigurationOperationsTest { assertEquals(false, Files.exists(directory.resolve("configuration-audit.pending"))); } + @Test void auditRepairRejectsOversizedActiveSegmentWithoutLoadingIt() throws Exception { + Path active = directory.resolve("configuration-audit.jsonl"); + Files.writeString(active, "x".repeat(70 * 1024)); + Map pending = new LinkedHashMap<>(); + pending.put("line", "{}" + System.lineSeparator()); + pending.put("rotate", false); + pending.put("preActiveHash", "GENESIS"); + pending.put("preActiveRecords", 0); + pending.put("preRetainedHash", "GENESIS"); + pending.put("preRetainedRecords", 0); + pending.put("preActiveBytes", 0); + pending.put("postActiveHash", "a".repeat(64)); + pending.put("postActiveRecords", 1); + pending.put("postRetainedHash", "GENESIS"); + pending.put("postRetainedRecords", 0); + Files.write(directory.resolve("configuration-audit.pending"), new ObjectMapper().writeValueAsBytes(pending)); + + assertThrows(java.io.IOException.class, () -> new ConfigurationAuditLog(directory, + Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC), 1)); + } + @Test void auditPendingTransactionRestoresMissingRecordSeparator() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); Path active = directory.resolve("configuration-audit.jsonl"); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 0753427c..0d4b4d9f 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -77,6 +77,10 @@ class ControlHttpServerTest { assertTrue(script.body().contains("approvedPreview.nodeIds.every")); assertTrue(script.body().contains("selectedCapabilitiesChanged")); assertTrue(script.body().contains("discardAuthenticationState")); + assertTrue(script.body().contains("text(operationStatus, '');")); + assertTrue(script.body().contains("text(fileOperationStatus, '');")); + assertTrue(script.body().contains("text(quickOperationStatus, '');")); + assertTrue(script.body().contains("Sign out could not be confirmed")); assertTrue(script.body().contains("result.success && result.configuration")); assertEquals(200, get("/app.css", null).statusCode()); assertError(send("POST", "/", null, null), 405, "METHOD_NOT_ALLOWED"); From 93980ab46ca200a2252e6f002e4a86f2a20e3a22 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 27 Aug 2026 21:04:34 -0600 Subject: [PATCH 39/42] Clear authenticated form state on teardown --- src/main/resources/web/app.js | 10 ++++++++-- .../control/http/ControlHttpServerTest.java | 2 ++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index a3cedf1e..ad72a122 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -17,12 +17,15 @@ const readConfiguration = document.querySelector('#read-configuration'); const previewConfiguration = document.querySelector('#preview-configuration'); const applyConfiguration = document.querySelector('#apply-configuration'); const operationStatus = document.querySelector('#operation-status'); +const configurationForm = document.querySelector('#configuration-form'); const configurationFile = document.querySelector('#configuration-file'); const configurationContent = document.querySelector('#configuration-content'); const readFileConfiguration = document.querySelector('#read-file-configuration'); const previewFileConfiguration = document.querySelector('#preview-file-configuration'); const applyFileConfiguration = document.querySelector('#apply-file-configuration'); const fileOperationStatus = document.querySelector('#file-operation-status'); +const fileConfigurationForm = document.querySelector('#file-configuration-form'); +const quickSetupForm = document.querySelector('#quick-setup-form'); const quickPreset = document.querySelector('#quick-preset'); const quickName = document.querySelector('#quick-name'); const quickService = document.querySelector('#quick-service'); @@ -218,8 +221,11 @@ function discardAuthenticationState(reason) { selectedNodes.clear(); nodeCapabilities.clear(); nodePlugins.clear(); - configurationContent.value = ''; - blockedServers.value = ''; + configurationForm.reset(); + fileConfigurationForm.reset(); + quickSetupForm.reset(); + quickCommandSuggestions.replaceChildren(); + text(detectedPlugins, 'Authenticate to inspect detected plugins.'); text(operationStatus, ''); text(fileOperationStatus, ''); text(quickOperationStatus, ''); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 0d4b4d9f..10956f92 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -80,6 +80,8 @@ class ControlHttpServerTest { assertTrue(script.body().contains("text(operationStatus, '');")); assertTrue(script.body().contains("text(fileOperationStatus, '');")); assertTrue(script.body().contains("text(quickOperationStatus, '');")); + assertTrue(script.body().contains("configurationForm.reset();")); + assertTrue(script.body().contains("quickCommandSuggestions.replaceChildren();")); assertTrue(script.body().contains("Sign out could not be confirmed")); assertTrue(script.body().contains("result.success && result.configuration")); assertEquals(200, get("/app.css", null).statusCode()); From fe68aa9e154797b09a39232f21825e5c80628c0f Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 27 Aug 2026 21:16:46 -0600 Subject: [PATCH 40/42] Enforce lease deadlines and bound audit reads --- .../control/domain/ConfigurationAuditLog.java | 25 ++++++++++++++----- .../domain/ConfigurationOperations.java | 4 ++- src/main/resources/web/app.js | 1 + .../domain/ConfigurationOperationsTest.java | 16 ++++++++++-- .../control/http/ControlHttpServerTest.java | 1 + 5 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java index afae97b2..dfe5fd88 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java @@ -116,7 +116,8 @@ private void recoverPending() throws IOException { throw new IOException("Configuration audit pending transaction is invalid"); } try { - JsonNode stored = json.readTree(Files.readAllBytes(pendingFile)); + JsonNode stored = json.readTree(readBounded(pendingFile, 16384, + "Configuration audit pending transaction is invalid")); if (stored == null || !stored.isObject() || stored.size() != 11 || !stored.path("rotate").isBoolean() || !integral(stored, "preActiveRecords") || !integral(stored, "preRetainedRecords") @@ -262,13 +263,24 @@ private SegmentState validateOptional(Path selected, String label) throws IOExce if (!Files.exists(selected, LinkOption.NOFOLLOW_LINKS)) return new SegmentState(false, "GENESIS", 0); if (!Files.isRegularFile(selected, LinkOption.NOFOLLOW_LINKS)) throw new IOException(label + " is not a regular file"); - if (Files.size(selected) > maxBytes + 64 * 1024) + long maximumSegmentBytes = Math.min(maxBytes, MAX_BYTES) + 64 * 1024; + if (Files.size(selected) > maximumSegmentBytes) throw new IOException(label + " exceeds its bounded size"); - return validateExisting(selected); + return validateBytes(readBounded(selected, maximumSegmentBytes, label + " exceeds its bounded size")); } - private SegmentState validateExisting(Path selected) throws IOException { - return validateBytes(Files.readAllBytes(selected)); + private static byte[] readBounded(Path selected, long maximumBytes, String failure) throws IOException { + if (maximumBytes < 0 || maximumBytes >= Integer.MAX_VALUE) throw new IOException(failure); + ByteBuffer buffer = ByteBuffer.allocate((int) maximumBytes + 1); + try (SeekableByteChannel channel = Files.newByteChannel(selected, + Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) { + while (channel.read(buffer) >= 0 && buffer.hasRemaining()) { } + } + if (!buffer.hasRemaining()) throw new IOException(failure); + buffer.flip(); + byte[] bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + return bytes; } private SegmentState validateBytes(byte[] bytes) throws IOException { @@ -328,7 +340,8 @@ private void validateCheckpoint() throws IOException { throw new IOException("Configuration audit checkpoint is missing or invalid"); } try { - JsonNode checkpoint = json.readTree(Files.readAllBytes(checkpointFile)); + JsonNode checkpoint = json.readTree(readBounded(checkpointFile, 4096, + "Configuration audit checkpoint is missing or invalid")); if (checkpoint == null || !checkpoint.isObject() || checkpoint.size() != 4 || !checkpoint.path("activeHash").asText().equals(previousHash) || !checkpoint.path("retainedHash").asText().equals(retainedHash) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 09a90e4e..63cf52ed 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -166,7 +166,9 @@ private OperationView completeCurrentSession(UUID operationId, String nodeId, Co if (!"IN_PROGRESS".equals(operation.states.get(nodeId))) { throw new ValidationException("TASK_NOT_CLAIMED", "Operation task must be claimed before completion", List.of()); } - if (!Objects.equals(operation.attemptIds.get(nodeId), result.attemptId())) { + Instant leasedAt = operation.leasedAt.get(nodeId); + if (!Objects.equals(operation.attemptIds.get(nodeId), result.attemptId()) || leasedAt == null + || !clock.instant().isBefore(leasedAt.plus(LEASE))) { throw new ValidationException("TASK_LEASE_EXPIRED", "Operation task lease is no longer active", List.of()); } validateResultConfiguration(operation, result); diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index ad72a122..98b2a6c8 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -224,6 +224,7 @@ function discardAuthenticationState(reason) { configurationForm.reset(); fileConfigurationForm.reset(); quickSetupForm.reset(); + updateQuickFields(); quickCommandSuggestions.replaceChildren(); text(detectedPlugins, 'Authenticate to inspect detected plugins.'); text(operationStatus, ''); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 82aa5e6b..c2af9159 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -212,6 +212,13 @@ class ConfigurationOperationsTest { Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC), 1)); } + @Test void auditValidationRejectsOversizedActiveSegmentWithoutLoadingIt() throws Exception { + Files.writeString(directory.resolve("configuration-audit.jsonl"), "x".repeat(70 * 1024)); + + assertThrows(java.io.IOException.class, () -> new ConfigurationAuditLog(directory, + Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC), 1)); + } + @Test void auditPendingTransactionRestoresMissingRecordSeparator() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); Path active = directory.resolve("configuration-audit.jsonl"); @@ -343,7 +350,7 @@ class ConfigurationOperationsTest { () -> operations.get(operation)).code()); } - @Test void expiredLeaseResultCannotCompleteAReissuedAttemptForTheSameSession() throws Exception { + @Test void expiredLeaseResultCannotCompleteBeforeOrAfterReissueForTheSameSession() throws Exception { MutableClock clock = new MutableClock(Instant.parse("2026-08-25T00:00:00Z")); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofHours(1)); UUID session = UUID.randomUUID(); @@ -354,9 +361,14 @@ class ConfigurationOperationsTest { ConfigurationTask first = operations.claim("proxy-a", session); clock.advance(Duration.ofMinutes(2)); + ProxyRoutingConfiguration configuration = new ProxyRoutingConfiguration(false, List.of()); + ConfigurationTaskResult expired = new ConfigurationTaskResult(session, true, "OK", "read", "a".repeat(64), + configuration, List.of(), false, false, first.attemptId()); + assertEquals("TASK_LEASE_EXPIRED", assertThrows(ValidationException.class, + () -> operations.complete(operation, "proxy-a", expired)).code()); + ConfigurationTask second = operations.claim("proxy-a", session); assertTrue(!first.attemptId().equals(second.attemptId())); - ProxyRoutingConfiguration configuration = new ProxyRoutingConfiguration(false, List.of()); ConfigurationTaskResult stale = new ConfigurationTaskResult(session, true, "OK", "read", "a".repeat(64), configuration, List.of(), false, false, first.attemptId()); assertEquals("TASK_LEASE_EXPIRED", assertThrows(ValidationException.class, diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 10956f92..8d397d9f 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -81,6 +81,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("text(fileOperationStatus, '');")); assertTrue(script.body().contains("text(quickOperationStatus, '');")); assertTrue(script.body().contains("configurationForm.reset();")); + assertTrue(script.body().contains("quickSetupForm.reset();\n updateQuickFields();")); assertTrue(script.body().contains("quickCommandSuggestions.replaceChildren();")); assertTrue(script.body().contains("Sign out could not be confirmed")); assertTrue(script.body().contains("result.success && result.configuration")); From cc3553c4e84a0cc46bc2ac379e59fca15041ecf9 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 27 Aug 2026 21:24:11 -0600 Subject: [PATCH 41/42] Validate audit lines without list amplification --- .../control/domain/ConfigurationAuditLog.java | 8 +++++--- .../domain/ConfigurationOperationsTest.java | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java index dfe5fd88..82eda338 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java @@ -17,6 +17,7 @@ import java.security.MessageDigest; import java.time.Clock; import java.util.HexFormat; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; @@ -292,16 +293,17 @@ private SegmentState validateBytes(byte[] bytes) throws IOException { .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) .decode(java.nio.ByteBuffer.wrap(bytes)).toString(); - return validateLines(content.lines().toList()); + return validateLines(content.lines().iterator()); } catch (java.nio.charset.CharacterCodingException e) { throw new IOException("Configuration audit chain is invalid", e); } } - private SegmentState validateLines(Iterable lines) throws IOException { + private SegmentState validateLines(Iterator lines) throws IOException { String expectedPrevious = "GENESIS"; long records = 0; - for (String line : lines) { + while (lines.hasNext()) { + String line = lines.next(); if (line.isBlank()) continue; try { JsonNode stored = json.readTree(line); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index c2af9159..48bf5bfc 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -219,6 +219,20 @@ class ConfigurationOperationsTest { Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC), 1)); } + @Test void newlineHeavyAuditSegmentIsValidatedLazily() throws Exception { + Files.writeString(directory.resolve("configuration-audit.jsonl"), "\n".repeat(1024 * 1024)); + Map checkpoint = new LinkedHashMap<>(); + checkpoint.put("activeHash", "GENESIS"); + checkpoint.put("activeRecords", 0); + checkpoint.put("retainedHash", "GENESIS"); + checkpoint.put("retainedRecords", 0); + Files.write(directory.resolve("configuration-audit.checkpoint"), + new ObjectMapper().writeValueAsBytes(checkpoint)); + + try (ConfigurationAuditLog ignored = new ConfigurationAuditLog(directory, + Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC))) { } + } + @Test void auditPendingTransactionRestoresMissingRecordSeparator() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); Path active = directory.resolve("configuration-audit.jsonl"); From a7f4e2b0aeff6c7492b178ccddd0685f0ca9fdb3 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 27 Aug 2026 21:42:15 -0600 Subject: [PATCH 42/42] Throttle password failures before derivation --- .../control/domain/ConfigurationAuditLog.java | 153 +++++++++++++----- .../control/http/ControlHttpServer.java | 69 +++++++- .../domain/ConfigurationOperationsTest.java | 18 ++- .../control/http/ControlHttpServerTest.java | 31 ++++ 4 files changed, 230 insertions(+), 41 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java index 82eda338..82ea82b5 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java @@ -26,6 +26,7 @@ /** Local append-only, hash-chained operation metadata. Values and approval tokens are never recorded. */ public final class ConfigurationAuditLog implements AutoCloseable { private static final long MAX_BYTES = 5L * 1024 * 1024; + private static final int MAX_RECORD_BYTES = 16 * 1024; private final Path file; private final Path previousFile; private final Path checkpointFile; @@ -39,6 +40,8 @@ public final class ConfigurationAuditLog implements AutoCloseable { private long activeRecords; private String retainedHash = "GENESIS"; private long retainedRecords; + private long activeBytes; + private long retainedBytes; public ConfigurationAuditLog(Path dataDirectory, Clock clock) throws IOException { this(dataDirectory, clock, MAX_BYTES); @@ -66,8 +69,10 @@ public ConfigurationAuditLog(Path dataDirectory, Clock clock) throws IOException SegmentState active = validateOptional(file, "Configuration audit log"); retainedHash = retained.tailHash(); retainedRecords = retained.records(); + retainedBytes = retained.bytes(); previousHash = active.tailHash(); activeRecords = active.records(); + activeBytes = active.bytes(); boolean hasSegments = retained.present() || active.present(); if (hasSegments) { validateCheckpoint(); @@ -87,7 +92,11 @@ public synchronized void append(String action, UUID operationId, String nodeId, // A prior append may have changed a segment before checkpoint publication failed. // Never replace its only durable recovery description with a new transaction. if (Files.exists(pendingFile, LinkOption.NOFOLLOW_LINKS)) recoverPending(); - boolean rotate = Files.exists(file) && Files.size(file) >= maxBytes; + validateExpectedTail(previousFile, retainedHash, retainedRecords, retainedBytes, + "Retained configuration audit log"); + validateExpectedTail(file, previousHash, activeRecords, activeBytes, + "Configuration audit log"); + boolean rotate = activeBytes >= maxBytes; String recordPrevious = rotate ? "GENESIS" : previousHash; Map core = new LinkedHashMap<>(); core.put("time", clock.instant().toString()); @@ -100,12 +109,11 @@ public synchronized void append(String action, UUID operationId, String nodeId, String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical)); core.put("hash", hash); String line = json.writeValueAsString(core) + System.lineSeparator(); - long preActiveBytes = Files.exists(file, LinkOption.NOFOLLOW_LINKS) ? Files.size(file) : 0; PendingAppend pending = new PendingAppend(line, rotate, previousHash, activeRecords, retainedHash, - retainedRecords, preActiveBytes, hash, rotate ? 1 : activeRecords + 1, + retainedRecords, activeBytes, hash, rotate ? 1 : activeRecords + 1, rotate ? previousHash : retainedHash, rotate ? activeRecords : retainedRecords); writePending(pending); - completePending(pending); + completeFreshPending(pending); } catch (Exception e) { throw new AuditException(e); } @@ -169,12 +177,27 @@ && matches(retained, pending.postRetainedHash(), pending.postRetainedRecords())) } else { throw new IOException("Configuration audit pending transaction does not match the audit chain"); } + applyPendingState(pending); + writeCheckpoint(); + deleteDurably(pendingFile); + } + + private void completeFreshPending(PendingAppend pending) throws IOException { + if (pending.rotate()) moveReplacing(file, previousFile); + appendDurably(pending.line()); + applyPendingState(pending); + writeCheckpoint(); + deleteDurably(pendingFile); + } + + private void applyPendingState(PendingAppend pending) { + int appendedBytes = pending.line().getBytes(StandardCharsets.UTF_8).length; retainedHash = pending.postRetainedHash(); retainedRecords = pending.postRetainedRecords(); + retainedBytes = pending.rotate() ? pending.preActiveBytes() : retainedBytes; previousHash = pending.postActiveHash(); activeRecords = pending.postActiveRecords(); - writeCheckpoint(); - deleteDurably(pendingFile); + activeBytes = (pending.rotate() ? 0 : pending.preActiveBytes()) + appendedBytes; } private SegmentState repairTornActive(PendingAppend pending, SegmentState retained, @@ -261,7 +284,7 @@ private static boolean integral(JsonNode object, String name) { } private SegmentState validateOptional(Path selected, String label) throws IOException { - if (!Files.exists(selected, LinkOption.NOFOLLOW_LINKS)) return new SegmentState(false, "GENESIS", 0); + if (!Files.exists(selected, LinkOption.NOFOLLOW_LINKS)) return new SegmentState(false, "GENESIS", 0, 0); if (!Files.isRegularFile(selected, LinkOption.NOFOLLOW_LINKS)) throw new IOException(label + " is not a regular file"); long maximumSegmentBytes = Math.min(maxBytes, MAX_BYTES) + 64 * 1024; @@ -293,47 +316,102 @@ private SegmentState validateBytes(byte[] bytes) throws IOException { .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) .decode(java.nio.ByteBuffer.wrap(bytes)).toString(); - return validateLines(content.lines().iterator()); + return validateLines(content.lines().iterator(), bytes.length); } catch (java.nio.charset.CharacterCodingException e) { throw new IOException("Configuration audit chain is invalid", e); } } - private SegmentState validateLines(Iterator lines) throws IOException { + private SegmentState validateLines(Iterator lines, long bytes) throws IOException { String expectedPrevious = "GENESIS"; long records = 0; while (lines.hasNext()) { String line = lines.next(); if (line.isBlank()) continue; - try { - JsonNode stored = json.readTree(line); - if (stored == null || !stored.isObject() || stored.size() != 7 - || !stored.has("time") || !stored.has("action") || !stored.has("operationId") - || !stored.has("nodeId") || !stored.has("outcome") || !stored.has("previousHash") - || !stored.has("hash")) { - throw new IOException("Configuration audit chain is invalid"); - } - Map core = new LinkedHashMap<>(); - core.put("time", stored.path("time").asText()); - core.put("action", stored.path("action").asText()); - core.put("operationId", stored.path("operationId").isNull() ? null : stored.path("operationId").asText()); - core.put("nodeId", stored.path("nodeId").isNull() ? null : stored.path("nodeId").asText()); - core.put("outcome", stored.path("outcome").isNull() ? null : stored.path("outcome").asText()); - core.put("previousHash", stored.path("previousHash").asText()); - String calculated = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") - .digest(json.writeValueAsBytes(core))); - if (!expectedPrevious.equals(core.get("previousHash")) || !calculated.equals(stored.path("hash").asText())) { - throw new IOException("Configuration audit chain is invalid"); - } - expectedPrevious = calculated; - records++; - } catch (IOException e) { - throw e; - } catch (Exception e) { - throw new IOException("Configuration audit chain is invalid", e); + AuditRecord record = validateRecord(line); + if (!expectedPrevious.equals(record.previousHash())) { + throw new IOException("Configuration audit chain is invalid"); + } + expectedPrevious = record.hash(); + records++; + } + return new SegmentState(true, expectedPrevious, records, bytes); + } + + private AuditRecord validateRecord(String line) throws IOException { + try { + JsonNode stored = json.readTree(line); + if (stored == null || !stored.isObject() || stored.size() != 7 + || !stored.has("time") || !stored.has("action") || !stored.has("operationId") + || !stored.has("nodeId") || !stored.has("outcome") || !stored.has("previousHash") + || !stored.has("hash")) { + throw new IOException("Configuration audit chain is invalid"); + } + Map core = new LinkedHashMap<>(); + core.put("time", stored.path("time").asText()); + core.put("action", stored.path("action").asText()); + core.put("operationId", stored.path("operationId").isNull() ? null : stored.path("operationId").asText()); + core.put("nodeId", stored.path("nodeId").isNull() ? null : stored.path("nodeId").asText()); + core.put("outcome", stored.path("outcome").isNull() ? null : stored.path("outcome").asText()); + core.put("previousHash", stored.path("previousHash").asText()); + String calculated = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(json.writeValueAsBytes(core))); + if (!calculated.equals(stored.path("hash").asText())) { + throw new IOException("Configuration audit chain is invalid"); + } + return new AuditRecord(stored.path("previousHash").asText(), calculated); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException("Configuration audit chain is invalid", e); + } + } + + private void validateExpectedTail(Path selected, String expectedHash, long expectedRecords, + long expectedBytes, String label) throws IOException { + if (!Files.exists(selected, LinkOption.NOFOLLOW_LINKS)) { + if (expectedBytes == 0 && expectedRecords == 0 && "GENESIS".equals(expectedHash)) return; + throw new IOException(label + " no longer matches its validated state"); + } + if (!Files.isRegularFile(selected, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException(label + " is not a regular file"); + } + try (SeekableByteChannel channel = Files.newByteChannel(selected, + Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) { + long size = channel.size(); + if (size != expectedBytes || size > Math.min(maxBytes, MAX_BYTES) + 64 * 1024) { + throw new IOException(label + " no longer matches its validated state"); + } + if (expectedRecords == 0) return; + int window = (int) Math.min(size, MAX_RECORD_BYTES + 2L); + ByteBuffer buffer = ByteBuffer.allocate(window); + channel.position(size - window); + while (buffer.hasRemaining() && channel.read(buffer) >= 0) { } + if (channel.size() != size || buffer.position() != window) { + throw new IOException(label + " changed during validation"); + } + byte[] tail = buffer.array(); + if (tail.length == 0 || tail[tail.length - 1] != '\n') { + throw new IOException(label + " is missing its terminal separator"); + } + int previousSeparator = tail.length - 2; + while (previousSeparator >= 0 && tail[previousSeparator] != '\n') previousSeparator--; + if (previousSeparator < 0 && size > tail.length) { + throw new IOException(label + " has an oversized final record"); + } + int start = previousSeparator + 1; + int length = tail.length - start - 1; + if (length <= 0 || length > MAX_RECORD_BYTES) { + throw new IOException(label + " has an invalid final record"); + } + String line = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) + .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(tail, start, length)).toString(); + if (!expectedHash.equals(validateRecord(line).hash())) { + throw new IOException(label + " no longer matches its validated state"); } } - return new SegmentState(true, expectedPrevious, records); } private void validateCheckpoint() throws IOException { @@ -415,7 +493,8 @@ public synchronized void close() throws IOException { lockChannel.close(); } - private record SegmentState(boolean present, String tailHash, long records) { } + private record SegmentState(boolean present, String tailHash, long records, long bytes) { } + private record AuditRecord(String previousHash, String hash) { } private record PendingAppend(String line, boolean rotate, String preActiveHash, long preActiveRecords, String preRetainedHash, long preRetainedRecords, long preActiveBytes, String postActiveHash, diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index b95a5b79..c9a9041d 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -39,6 +39,7 @@ import java.time.Clock; import java.time.Instant; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -71,6 +72,8 @@ public final class ControlHttpServer implements AutoCloseable { "/app.css", new WebResource("/web/app.css", "text/css; charset=utf-8")); private static final int MAX_AUTH_FAILURES_PER_MINUTE = 100; private static final int MAX_PASSWORD_ATTEMPTS_PER_CLIENT = 8; + private static final int MAX_PASSWORD_FAILURES_PER_CLIENT = 5; + private static final int MAX_PASSWORD_FAILURE_CLIENTS = 4096; static final int MAX_BACKENDS_PER_NODE_PAGE = 4096; private static final int MAX_BACKENDS_PER_NODE_SUMMARY = 256; @@ -83,6 +86,7 @@ public final class ControlHttpServer implements AutoCloseable { private final ThreadPoolExecutor executor; private final ThreadPoolExecutor passwordExecutor; private final PasswordAdmission passwordAdmission = new PasswordAdmission(MAX_PASSWORD_ATTEMPTS_PER_CLIENT); + private final PasswordFailureLimiter passwordFailureLimiter; private final AuthFailureLimiter authLimiter; private final WebSessionStore webSessions; private final boolean secureCookies; @@ -153,6 +157,8 @@ public ControlHttpServer(InetSocketAddress address, NodeRegistry registry, Contr .maxNestingDepth(20).maxStringLength(ManagedConfiguration.MAX_CONTENT + 4096) .maxNumberLength(64).build()); authLimiter = new AuthFailureLimiter(nanoTime, MAX_AUTH_FAILURES_PER_MINUTE, TimeUnit.MINUTES.toNanos(1)); + passwordFailureLimiter = new PasswordFailureLimiter(nanoTime, MAX_PASSWORD_FAILURES_PER_CLIENT, + TimeUnit.MINUTES.toNanos(1), MAX_PASSWORD_FAILURE_CLIENTS); webSessions = new WebSessionStore(clock); server = HttpServer.create(address, 32); server.createContext("/", this::handle); @@ -261,13 +267,16 @@ private void route(HttpExchange exchange) throws IOException { PasswordRequest request = read(exchange, PasswordRequest.class); requireRequest(request); String client = passwordClient(exchange); + if (!passwordFailureLimiter.allowAttempt(client)) { + throw new AuthenticationException(true); + } if (!passwordAdmission.acquire(client)) { throw new AuthenticationException(true); } try { passwordExecutor.execute(() -> { try { - handlePasswordLogin(exchange, request); + handlePasswordLogin(exchange, request, client); } finally { passwordAdmission.release(client); } @@ -399,12 +408,14 @@ private void route(HttpExchange exchange) throws IOException { error(exchange, 404, "NOT_FOUND", "Endpoint not found", List.of()); } - private void handlePasswordLogin(HttpExchange exchange, PasswordRequest request) { + private void handlePasswordLogin(HttpExchange exchange, PasswordRequest request, String client) { try { String credentialRevision = credentials.authenticateWebPassword(request.password()); if (credentialRevision == null) { + passwordFailureLimiter.recordFailure(client); authenticationFailed(); } + passwordFailureLimiter.clear(client); webSessions.remove(cookie(exchange, SESSION_COOKIE)); WebSessionStore.Session session = webSessions.create(credentialRevision); setSessionCookie(exchange, session.id(), false); @@ -785,4 +796,58 @@ synchronized void release(String client) { } } } + + static final class PasswordFailureLimiter { + private final java.util.function.LongSupplier nanoTime; + private final int maximumFailures; + private final long windowNanos; + private final int maximumClients; + private final LinkedHashMap failures = new LinkedHashMap<>(16, 0.75f, true); + + PasswordFailureLimiter(java.util.function.LongSupplier nanoTime, int maximumFailures, + long windowNanos, int maximumClients) { + this.nanoTime = nanoTime; + this.maximumFailures = maximumFailures; + this.windowNanos = windowNanos; + this.maximumClients = maximumClients; + } + + synchronized boolean allowAttempt(String client) { + FailureWindow current = current(client); + return current == null || current.failures() < maximumFailures; + } + + synchronized void recordFailure(String client) { + long now = nanoTime.getAsLong(); + FailureWindow current = current(client); + if (current == null) { + while (failures.size() >= maximumClients) { + var oldest = failures.entrySet().iterator(); + if (!oldest.hasNext()) break; + oldest.next(); + oldest.remove(); + } + failures.put(client, new FailureWindow(now, 1)); + } else if (current.failures() < maximumFailures) { + failures.put(client, new FailureWindow(current.started(), current.failures() + 1)); + } + } + + synchronized void clear(String client) { + failures.remove(client); + } + + private FailureWindow current(String client) { + FailureWindow current = failures.get(client); + if (current == null) return null; + long now = nanoTime.getAsLong(); + if (now < current.started() || now - current.started() >= windowNanos) { + failures.remove(client); + return null; + } + return current; + } + + private record FailureWindow(long started, int failures) { } + } } diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 48bf5bfc..a97a2d39 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -229,8 +229,22 @@ class ConfigurationOperationsTest { Files.write(directory.resolve("configuration-audit.checkpoint"), new ObjectMapper().writeValueAsBytes(checkpoint)); - try (ConfigurationAuditLog ignored = new ConfigurationAuditLog(directory, - Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC))) { } + try (ConfigurationAuditLog log = new ConfigurationAuditLog(directory, + Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC))) { + log.append("AFTER_BLANKS", UUID.randomUUID(), "proxy-a", "OK"); + } + } + + @Test void liveAuditAppendRejectsAChangedTail() throws Exception { + Path active = directory.resolve("configuration-audit.jsonl"); + try (ConfigurationAuditLog log = new ConfigurationAuditLog(directory, + Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC))) { + log.append("FIRST", UUID.randomUUID(), "proxy-a", "OK"); + Files.writeString(active, Files.readString(active).replaceFirst("FIRST", "OTHER")); + + assertThrows(ConfigurationAuditLog.AuditException.class, + () -> log.append("SECOND", UUID.randomUUID(), "proxy-a", "OK")); + } } @Test void auditPendingTransactionRestoresMissingRecordSeparator() throws Exception { diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 8d397d9f..293e6574 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -250,6 +250,17 @@ class ControlHttpServerTest { } } + @Test void repeatedPasswordFailuresAreThrottledBeforeMoreDerivations() throws Exception { + credentials.setWebPassword("a-secure-web-password".toCharArray()); + for (int attempt = 0; attempt < 5; attempt++) { + assertAuthFailure(send("POST", "/api/v1/auth/login", + "{\"password\":\"invalid-password-" + attempt + "\"}", null)); + } + + assertError(send("POST", "/api/v1/auth/login", + "{\"password\":\"another-invalid-password\"}", null), 429, "AUTH_RATE_LIMITED"); + } + @Test void passwordAdmissionPreventsOneClientFromOccupyingAllVerificationCapacity() { ControlHttpServer.PasswordAdmission admission = new ControlHttpServer.PasswordAdmission(2); assertTrue(admission.acquire("attacker")); @@ -261,6 +272,26 @@ class ControlHttpServerTest { admission.release("owner"); } + @Test void passwordFailureLimiterExpiresClearsAndBoundsClients() { + java.util.concurrent.atomic.AtomicLong now = new java.util.concurrent.atomic.AtomicLong(); + ControlHttpServer.PasswordFailureLimiter limiter = + new ControlHttpServer.PasswordFailureLimiter(now::get, 2, 10, 2); + assertTrue(limiter.allowAttempt("attacker")); + limiter.recordFailure("attacker"); + assertTrue(limiter.allowAttempt("attacker")); + limiter.recordFailure("attacker"); + assertFalse(limiter.allowAttempt("attacker")); + limiter.clear("attacker"); + assertTrue(limiter.allowAttempt("attacker")); + + limiter.recordFailure("attacker"); + limiter.recordFailure("second"); + limiter.recordFailure("third"); + assertTrue(limiter.allowAttempt("attacker")); + now.set(10); + assertTrue(limiter.allowAttempt("second")); + } + @Test void trustedProxyUsesForwardedClientWhileDirectPeersCannotSpoofIt() { assertEquals("203.0.113.9", ControlHttpServer.forwardedPasswordClient("127.0.0.1", "203.0.113.9", java.util.Set.of("127.0.0.1")));