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/.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/.gitignore b/.gitignore new file mode 100644 index 00000000..d29a25c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target/ +/.idea/ +*.iml +/data/ diff --git a/README.md b/README.md new file mode 100644 index 00000000..619b78ef --- /dev/null +++ b/README.md @@ -0,0 +1,192 @@ +# 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, 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 + +One Control process can observe an entire network: + +```text +Browser -> Control WebUI/API <- Proxy A / Proxy B / Proxy C + ^--- Bukkit backend A / B / C +``` + +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, +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 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. + +## Requirements, build, and run + +- 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 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 | Constraint | +| --- | --- | --- | +| `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 | +| `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 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. + +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 +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 + +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. + +```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 + +# 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 + +# 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 +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` | `/`, `/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 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` | +| `POST` | `/api/v1/nodes/{nodeId}/operations/{operationId}/result` | matching node | Complete that node's task | +| `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. + +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. 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. +`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 (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. + +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 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. 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. 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. + +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. + +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. + +## 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 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. +- 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. + +## Intentionally out of scope + +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/pom.xml b/pom.xml new file mode 100644 index 00000000..00221b4b --- /dev/null +++ b/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + com.bencodez + votingplugin-control + 0.1.0-SNAPSHOT + + 17 + 3.9.0 + 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-enforcer-plugin3.5.0 + enforce + [17,) + [${maven.version},) + + + + org.apache.maven.pluginsmaven-compiler-plugin3.13.0 + + + 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 + + + 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..ea420547 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/ControlApplication.java @@ -0,0 +1,324 @@ +package com.bencodez.votingplugin.control; + +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; +import java.io.IOException; +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; +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; + +public final class ControlApplication { + private static final Object IDENTITY_LOCK = new Object(); + private static final int MAX_IDENTITY_BYTES = 64; + + private ControlApplication() { } + + 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 { + 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) { + throw new IllegalArgumentException("Usage: enroll [dataDirectory]"); + } + 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]"); + } + 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 = 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")); + } + + 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() && !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())); + System.setProperty("sun.net.httpserver.maxRspTime", Integer.toString(configuration.requestTimeoutSeconds())); + ControlIdentity identity = new ControlIdentity(loadIdentity(configuration.dataDirectory()), + VersionInfo.applicationVersion(), Protocol.VERSION); + 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, 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); + try { + shutdown.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + server.close(); + } + } + + /** 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"); + 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(); + 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); + } + DurableFiles.forceDirectory(directory); + } finally { + Files.deleteIfExists(temporary); + } + return candidate; + } + } + } + + private static UUID readIdentity(Path file) throws IOException { + try { + 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"); + } + } + + 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); + } + + 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, Long parentPid) { + 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); + boolean secureCookies = strictBoolean(environment.getOrDefault("CONTROL_SECURE_COOKIE", "false"), + "secure cookie"); + 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, + parentPid); + } + + 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 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<>(); + 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) { + 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; + } + + 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/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/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..9c7c6192 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/auth/CredentialStore.java @@ -0,0 +1,324 @@ +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; +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; +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.Set; +import java.util.regex.Pattern; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; + +/** 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 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; + 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; + 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(); + } + + /** 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 { + 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, LinkOption.NOFOLLOW_LINKS)) { + return new StoreData(); + } + 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) { + 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 (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); + } + DurableFiles.forceDirectory(directory); + } 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 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()); + } + } + + @FunctionalInterface + private interface Mutation { + void apply(StoreData data); + } + + 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/domain/ConfigurationAuditLog.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java new file mode 100644 index 00000000..82ea82b5 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationAuditLog.java @@ -0,0 +1,507 @@ +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; +import java.nio.ByteBuffer; +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.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.Iterator; +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. */ +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; + private final Path pendingFile; + private final FileChannel lockChannel; + private final FileLock processLock; + 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; + private long activeBytes; + private long retainedBytes; + + 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.pendingFile = dataDirectory.resolve("configuration-audit.pending"); + this.clock = clock; + this.maxBytes = maxBytes; + 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; + recoverPending(); + SegmentState retained = validateOptional(previousFile, "Retained configuration audit log"); + 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(); + } 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); + } + } + + 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(); + 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()); + core.put("action", action); + core.put("operationId", operationId == null ? null : operationId.toString()); + core.put("nodeId", nodeId); + core.put("outcome", outcome); + core.put("previousHash", recordPrevious); + byte[] canonical = json.writeValueAsBytes(core); + String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical)); + core.put("hash", hash); + String line = json.writeValueAsString(core) + System.lineSeparator(); + PendingAppend pending = new PendingAppend(line, rotate, previousHash, activeRecords, retainedHash, + retainedRecords, activeBytes, hash, rotate ? 1 : activeRecords + 1, + rotate ? previousHash : retainedHash, rotate ? activeRecords : retainedRecords); + writePending(pending); + completeFreshPending(pending); + } catch (Exception e) { + throw new AuditException(e); + } + } + + 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(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") + || !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) { + 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; + 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()) + && !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. + } 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"); + } + 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(); + activeBytes = (pending.rotate() ? 0 : pending.preActiveBytes()) + appendedBytes; + } + + private SegmentState repairTornActive(PendingAppend pending, SegmentState retained, + IOException invalidActive) throws IOException { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw invalidActive; + byte[] intended = pending.line().getBytes(StandardCharsets.UTF_8); + long baseLong = pending.rotate() ? 0 : pending.preActiveBytes(); + 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; + } 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) { + deleteDurably(file); + } else { + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) { + channel.truncate(base); + channel.force(true); + } + } + 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)); + } + + 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 { + 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("preActiveBytes", pending.preActiveBytes()); + 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, 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; + if (Files.size(selected) > maximumSegmentBytes) + throw new IOException(label + " exceeds its bounded size"); + return validateBytes(readBounded(selected, maximumSegmentBytes, label + " exceeds its bounded size")); + } + + 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 { + 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) + .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) + .decode(java.nio.ByteBuffer.wrap(bytes)).toString(); + 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, long bytes) throws IOException { + String expectedPrevious = "GENESIS"; + long records = 0; + while (lines.hasNext()) { + String line = lines.next(); + if (line.isBlank()) continue; + 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"); + } + } + } + + 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(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) + || !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); + 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, 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, target, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (java.nio.file.AtomicMoveNotSupportedException e) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + forceDirectory(target.getParent()); + } 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); + } + 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 { + DurableFiles.forceDirectory(directory); + } + + @Override + public synchronized void close() throws IOException { + processLock.close(); + lockChannel.close(); + } + + 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, + long postActiveRecords, String postRetainedHash, long postRetainedRecords) { } + + @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..63cf52ed --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -0,0 +1,410 @@ +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 com.bencodez.votingplugin.control.protocol.ManagedConfiguration; +import java.nio.charset.StandardCharsets; +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.Iterator; +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 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 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); + + private final NodeRegistry registry; + private final ConfigurationAuditLog audit; + private final Clock clock; + 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); + this.audit = Objects.requireNonNull(audit); + this.clock = Objects.requireNonNull(clock); + } + + public synchronized OperationView createRead(List nodeIds) { + 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.capability()), 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()); + } + 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.id); + preview.approvalUsed = true; + 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, UUID sessionId) { + return registry.withSession(nodeId, sessionId, node -> claimCurrentSession(nodeId, node)); + } + + private ConfigurationTask claimCurrentSession(String nodeId, NodeStatus node) { + 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)))) { + if (!node.online() || !node.acceptedCapabilities().contains(operation.configuration.capability())) { + 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, 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), attemptId); + } + } + return null; + } + + public synchronized OperationView complete(UUID operationId, String nodeId, ConfigurationTaskResult result) { + validateResult(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) { + 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()); + } + 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()); + } + 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); + 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); + } + + private OperationView create(String type, List targets, ManagedConfiguration config, String token) { + StoredOperation operation = store(type, targets, config, token, Map.of(), null); + 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, ManagedConfiguration config, + 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(); + 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<>(), new LinkedHashMap<>(revisions)); + operations.put(id, result); + 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"); + releaseResultDetails(candidate); + iterator.remove(); + retained--; + } + } + if (retained >= MAX_FILE_OPERATIONS) { + throw new ValidationException("OPERATION_LIMIT", "Too many active file operations", List.of()); + } + } + + private ConfigurationTaskResult boundedResult(StoredOperation operation, ConfigurationTaskResult result) { + 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) + .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; + } + 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.attemptId()); + } + + 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(); + 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()) { + 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) { + 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 == null ? null : operation.configuration.publicView(), + Map.copyOf(operation.states), Map.copyOf(operation.results), approval); + } + + private void prune() { + 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(); + 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); + iterator.remove(); + } + } + } + + 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.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) + || (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}") + || result.configuration() == null)) { + throw invalid("successful result must include a typed configuration and revision"); + } + } + + @Override + public void close() throws java.io.IOException { + audit.close(); + } + + public record OperationView(UUID operationId, String type, String state, Instant createdAt, + ManagedConfiguration configuration, Map nodeStates, + Map results, String approvalToken) { } + + private static final class StoredOperation { + private final UUID id; + private final String type; + private final ManagedConfiguration configuration; + private final String approvalToken; + private final Instant createdAt; + private final LinkedHashMap states; + private final LinkedHashMap results; + private final LinkedHashMap leasedAt; + private final LinkedHashMap attemptIds; + private final LinkedHashMap expectedRevisions; + private boolean approvalUsed; + + private StoredOperation(UUID id, String type, ManagedConfiguration configuration, String approvalToken, + 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); } + private boolean fileOperation() { + return configuration != null && ManagedConfiguration.FILE.equals(configuration.domain()); + } + } +} 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..4c8b76ef --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -0,0 +1,349 @@ +package com.bencodez.votingplugin.control.domain; + +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 { + public static final Set SUPPORTED_CAPABILITIES = Set.of("discovery.read", "presence.snapshot", + 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", "BUKKIT"); + 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 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, 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"); + } + } + + @Override + 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(); + 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) -> { + 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(), + registration.displayName().trim(), registration.platform(), registration.pluginVersion().trim(), + registration.protocolVersion(), registration.capabilities(), accepted(registration.capabilities()), + registration.detectedPlugins(), backends, sequence, now, now); + result.set(replacement); + return replacement; + }); + retainedDetectedPlugins = replacementPluginTotal; + return new RegistrationResult(view(result.get()), created.get()); + } + + @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(), 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.detectedPlugins, 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 synchronized SnapshotResult replacePresence(String nodeId, PresenceSnapshot snapshot) { + 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) -> { + 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(); + 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; + }); + 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(); + } + + @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); + } + + /** Runs one task mutation while replacement registration for this node is excluded. */ + @Override + public T withSession(String nodeId, UUID sessionId, + java.util.function.Function 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.apply(view(existing))); + 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)); + return new NodeStatus(node.nodeId, node.sessionId, node.displayName, node.platform, node.pluginVersion, + node.protocolVersion, node.advertisedCapabilities, node.acceptedCapabilities, node.detectedPlugins, + 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))) return existing; + retainedDetectedPlugins -= existing.detectedPlugins.size(); + retainedBackends -= existing.backends.size(); + return null; + }); + } + } + + 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, VELOCITY, or BUKKIT"); + } + validateProtocol(registration.protocolVersion()); + validateCapabilities(registration.capabilities(), registration.requiredCapabilities()); + validateDetectedPlugins(registration.detectedPlugins()); + } + + 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 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); + 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, 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/domain/NodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java new file mode 100644 index 00000000..0c846e3e --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/domain/NodeRegistry.java @@ -0,0 +1,21 @@ +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 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); + NodeStatus find(String nodeId); + void requireSession(String nodeId, java.util.UUID sessionId); + 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/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..c9a9041d --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -0,0 +1,853 @@ +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; +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.ManagedConfiguration; +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.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 com.sun.net.httpserver.HttpsExchange; +import java.io.ByteArrayOutputStream; +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; +import java.nio.ByteBuffer; +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; +import java.util.LinkedHashMap; +import java.util.List; +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; +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 { + // 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"; + 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"), + "/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; + 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; + + private final HttpServer server; + private final ObjectMapper json; + private final NodeRegistry registry; + private final ControlIdentity identity; + private final CredentialStore credentials; + 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 PasswordFailureLimiter passwordFailureLimiter; + private final AuthFailureLimiter authLimiter; + 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, (String) 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(), 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(), 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(), 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, 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, 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(); + 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() + .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); + 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()); + 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); + } + + public void start() { + server.start(); + } + + public int port() { + return server.getAddress().getPort(); + } + + @Override + 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(); + } + 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 { + 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", + 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; + case "UNSUPPORTED_PROTOCOL", "INCOMPATIBLE_CAPABILITIES", "SESSION_MISMATCH", + "PREVIEW_INCOMPLETE", "APPROVAL_REQUIRED", "NODE_UNAVAILABLE", "OPERATION_LIMIT", + "REGISTRY_LIMIT", "TASK_NOT_CLAIMED", "TASK_LEASE_EXPIRED" -> 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 (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 { + if (!deferred) exchange.close(); + } + } + + 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"); + 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)) { + requireMethod(exchange, "POST"); + 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, client); + } finally { + passwordAdmission.release(client); + } + }); + } catch (RejectedExecutionException e) { + passwordAdmission.release(client); + throw new AuthenticationException(true); + } + throw new DeferredResponseException(); + } + 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, false); + Map query = query(uri.getRawQuery()); + int offset = integer(query.getOrDefault("offset", "0"), "offset"); + int limit = integer(query.getOrDefault("limit", "50"), "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)) { + 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; + } + if ((CONFIGURATION + "/read").equals(path)) { + requireMethod(exchange, "POST"); + authenticateAdmin(exchange, true); + ConfigurationRequests.Read request = read(exchange, ConfigurationRequests.Read.class); + 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; + } + if ((CONFIGURATION + "/apply").equals(path)) { + 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; + } + if (path != null && path.startsWith(OPERATIONS + "/")) { + String remainder = path.substring((OPERATIONS + "/").length()); + if (!remainder.contains("/")) { + requireMethod(exchange, "GET"); + authenticateAdmin(exchange, false); + 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]) + || "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 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); + requireRequest(claim); + ConfigurationTask task = configurationOperations.claim(nodeId, claim.sessionId()); + 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); + send(exchange, 200, configurationOperations.complete(UUID.fromString(segments[2]), nodeId, result)); + return; + } + } + error(exchange, 404, "NOT_FOUND", "Endpoint not found", List.of()); + } + + 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); + 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(); + } + } + + 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")) { + throw new ValidationException("UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json", List.of()); + } + 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(); 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 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)); + } + + 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) { + String token = bearer(exchange); + if (verifier.test(token)) { + return; + } + authenticationFailed(); + } + + private WebSessionStore.Session authenticateWebSession(HttpExchange exchange, boolean csrfRequired) { + WebSessionStore.Session session = webSessions.authenticate(cookie(exchange, SESSION_COOKIE), + credentials.webPasswordRevision()); + if (session == null) { + authenticationFailed(); + } + if (csrfRequired && !constantTimeEquals(session.csrfToken(), singleHeader(exchange, "X-CSRF-Token"))) { + throw new CsrfException(); + } + return session; + } + + private void authenticationFailed() { + if (!authLimiter.allowAttempt()) throw new AuthenticationException(true); + authLimiter.recordFailure(); + throw new AuthenticationException(false); + } + + 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 String passwordClient(HttpExchange exchange) { + InetSocketAddress remote = exchange.getRemoteAddress(); + if (remote == null) return "unknown"; + 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) { + String found = null; + List 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; + } + exchange.getResponseHeaders().set("Allow", expected); + 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); + 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())) { + 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); + } + } + + 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 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("&", -1)) { + String[] pair = item.split("=", 2); + 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 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 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) { } + record BackendPage(List items, int backendItemsReturned, boolean backendItemsTruncated) { } + + 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; + } + } + } + + 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); + } + } + } + + 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/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/ConfigurationRequests.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationRequests.java new file mode 100644 index 00000000..a1b63a9c --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationRequests.java @@ -0,0 +1,24 @@ +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, ManagedConfiguration configuration) { + public Read { nodeIds = nodeIds == null ? List.of() : List.copyOf(nodeIds); } + public Read(List nodeIds) { this(nodeIds, null); } + } + + 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) { } + + 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..ba3b8e67 --- /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, ManagedConfiguration configuration, + 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 new file mode 100644 index 00000000..314ecec4 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ConfigurationTaskResult.java @@ -0,0 +1,20 @@ +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, + ManagedConfiguration configuration, List changes, + 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, UUID attemptId) { + this(sessionId, success, code, message, revision, + configuration == null ? null : ManagedConfiguration.proxy(configuration), changes, reloaded, rolledBack, + attemptId); + } +} 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..e815c289 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/Heartbeat.java @@ -0,0 +1,13 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.util.Set; +import java.util.UUID; + +/** 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 ? Set.of() : Set.copyOf(capabilities); + requiredCapabilities = requiredCapabilities == null ? Set.of() : Set.copyOf(requiredCapabilities); + } +} 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..e10bf0df --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java @@ -0,0 +1,77 @@ +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 || 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"); + } + 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/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..d63422f1 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/NodeRegistration.java @@ -0,0 +1,22 @@ +package com.bencodez.votingplugin.control.protocol; + +import java.util.Set; +import java.util.UUID; + +/** 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 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 new file mode 100644 index 00000000..872db44d --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/NodeStatus.java @@ -0,0 +1,26 @@ +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, UUID sessionId, String displayName, String platform, String pluginVersion, + int protocolVersion, Set advertisedCapabilities, Set acceptedCapabilities, + 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/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/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/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/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/main/resources/web/app.css b/src/main/resources/web/app.css new file mode 100644 index 00000000..0ba87c4b --- /dev/null +++ b/src/main/resources/web/app.css @@ -0,0 +1,51 @@ +: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; } +.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; } +.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, 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; } +.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, #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; } +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; } +.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; } +@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 new file mode 100644 index 00000000..98b2a6c8 --- /dev/null +++ b/src/main/resources/web/app.js @@ -0,0 +1,609 @@ +'use strict'; + +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'); +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 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'); +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'); +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 nodePlugins = new Map(); +let inputGeneration = 0; +let authenticationGeneration = 0; +let loginInFlight = false; +let configurationOperationsInFlight = 0; + +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 selector = document.createElement('label'); + selector.className = 'node-select'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + 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++; + 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) { + list.append(text(document.createElement('li'), 'No backends reported.')); + } else { + backends.forEach(backend => list.append(backendCard(backend))); + } + article.append(selector, meta, pluginMeta, list); + return article; +} + +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; + 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(); +} + +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 requestGeneration = authenticationGeneration; + const method = (options.method || 'GET').toUpperCase(); + const response = await fetch(path, { + cache: 'no-store', + credentials: 'same-origin', + ...options, + headers: {...(options.headers || {}), ...(method === 'GET' ? {} : {'X-CSRF-Token': csrfToken})} + }); + const body = response.status === 204 ? null : await response.json(); + 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; +} + +function discardAuthenticationState(reason) { + authenticationGeneration++; + authenticated = false; + csrfToken = ''; + approvedPreview = null; + approvedFilePreview = null; + approvedQuickPreview = null; + inputGeneration++; + logout.hidden = true; + selectedNodes.clear(); + nodeCapabilities.clear(); + nodePlugins.clear(); + configurationForm.reset(); + fileConfigurationForm.reset(); + quickSetupForm.reset(); + updateQuickFields(); + quickCommandSuggestions.replaceChildren(); + text(detectedPlugins, 'Authenticate to inspect detected plugins.'); + text(operationStatus, ''); + text(fileOperationStatus, ''); + text(quickOperationStatus, ''); + nodes.replaceChildren(); + nodes.classList.add('empty'); + text(nodes, 'Authenticate to view the network.'); + text(message, reason); + updateConfigurationButtons(); +} + +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, 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(statusElement, operationSummary(operation)); + } + return operation; +} + +async function startConfigurationOperation(path, body, statusElement = operationStatus) { + configurationOperationsInFlight++; + updateConfigurationButtons(); + try { + return await waitForOperation(await authorized(path, { + method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body) + }), statusElement); + } finally { + configurationOperationsInFlight--; + updateConfigurationButtons(); + } +} + +async function loadNodes() { + if (!authenticated) return; + refresh.disabled = true; + previousPage.disabled = true; + nextPage.disabled = true; + text(message, 'Loading…'); + try { + 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) { + text(nodes, pageOffset === 0 ? 'No proxies have registered yet.' : 'No proxies on this page.'); + } 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 => + 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))); + if (filteredSelection.size !== selectedNodes.size) { + approvedPreview = null; + approvedFilePreview = null; + approvedQuickPreview = null; + inputGeneration++; + 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; + 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; + } 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', async event => { + event.preventDefault(); + if (loginInFlight) return; + loginInFlight = true; + loginButton.disabled = true; + logout.disabled = true; + const loginGeneration = ++authenticationGeneration; + 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.'); + if (loginGeneration !== authenticationGeneration) return; + authenticated = true; + csrfToken = body.csrfToken; + approvedPreview = null; + approvedFilePreview = null; + approvedQuickPreview = null; + selectedNodes.clear(); + configurationContent.value = ''; + inputGeneration++; + logout.hidden = false; + pageOffset = 0; + await loadNodes(); + } catch (error) { + text(message, error.message || 'Authentication failed.'); + } finally { + loginInFlight = false; + loginButton.disabled = false; + logout.disabled = false; + } +}); + +logout.addEventListener('click', async () => { + if (loginInFlight) return; + loginInFlight = true; + const logoutGeneration = ++authenticationGeneration; + loginButton.disabled = true; + logout.disabled = true; + try { + await authorized('/api/v1/auth/logout', {method: 'POST'}); + 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; + } +}); + +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; + await loadNodes(); + } catch (_) { /* The login form remains available. */ } +} + +readConfiguration.addEventListener('click', async () => { + approvedPreview = null; + 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 && readAuthenticationGeneration === authenticationGeneration + && readInputGeneration === inputGeneration) { + sendAll.checked = retained.configuration.sendVotesToAllServers; + blockedServers.value = retained.configuration.blockedServers.join('\n'); + approvedPreview = null; + inputGeneration++; + updateConfigurationButtons(); + } + } catch (error) { text(operationStatus, error.message); } +}); + +previewConfiguration.addEventListener('click', async () => { + approvedPreview = null; + const previewGeneration = inputGeneration; + try { + const operation = await startConfigurationOperation('/api/v1/configuration/preview', { + nodeIds: targets('config.proxy-routing.v1'), configuration: proposal() + }); + if (operation.state === 'SUCCEEDED' && operation.approvalToken + && previewGeneration === inputGeneration) { + 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.'); + } + } 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; + inputGeneration++; + 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; + inputGeneration++; + updateConfigurationButtons(); +})); + +readFileConfiguration.addEventListener('click', async () => { + approvedFilePreview = null; + 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: selectedFile} + }, fileOperationStatus); + const contentResult = Object.values(operation.results).find(result => + result.success && result.configuration?.content != null); + if (contentResult && authenticated && readAuthenticationGeneration === authenticationGeneration + && readInputGeneration === inputGeneration && selectedFile === configurationFile.value) { + configurationContent.value = contentResult.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, + nodeIds: targets('config.files.v1')}; + 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'}; + 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 () => { + 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, + nodeIds: targets('config.quick-setup.v1')}; + 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); } +}); + +[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', () => { + pageOffset = Math.max(0, pageOffset - PAGE_SIZE); + loadNodes(); +}); +nextPage.addEventListener('click', () => { + pageOffset += PAGE_SIZE; + loadNodes(); +}); +loadHealth(); +updateQuickFields(); +updatePluginSuggestions(); +restoreSession(); diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html new file mode 100644 index 00000000..56a8cb19 --- /dev/null +++ b/src/main/resources/web/index.html @@ -0,0 +1,154 @@ + + + + + + VotingPlugin Control + + + +
+
+
+

Local network console

+

VotingPlugin Control

+
+ Connecting… +
+ +
+

Network discovery

+

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

+
+ +
+ +
+ + +
+
+
+

+
+ +
+
+

Proxies and backends

+ +
+
Authenticate to view the network.
+ +
+ +
+
+
+

Proxy routing configuration

+

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

+
+
+
+ + + +
+ + + +
+
+
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 Bukkit nodes to see detected plugins and command suggestions.

+
+ +
+
+ + + +
+
+ + + + +
+
+ Common settings + + + + + + +
+
+ + + + + +
+
+ + +
+
+
Select at least one online Bukkit node with quick setup control.
+
+
+ + + 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..66a3b1bc --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/ControlApplicationTest.java @@ -0,0 +1,118 @@ +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; +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 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")); + } + + @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 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() 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"))); + 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()); + } +} 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..0c1bff81 --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/auth/CredentialStoreTest.java @@ -0,0 +1,66 @@ +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)); + } + + @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())); + } + + @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")); + } +} 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..a97a2d39 --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -0,0 +1,613 @@ +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 static org.junit.jupiter.api.Assertions.assertTrue; + +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 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; +import java.nio.file.Files; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +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; +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", 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, + List.of("blockedServers changed"), false, false, previewTask.attemptId())); + 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", registry.find("proxy-a").sessionId()); + 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"); + 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)); + } + + @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 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()); + 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, task.attemptId()); + 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 retained = directory.resolve("configuration-audit.jsonl.1"); + 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)); + } + + @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"); + 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 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"); + 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()); + 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"))); + } + + @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 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 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 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 { + 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 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)) { + 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); + 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, task.attemptId())); + 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()); + } + + @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 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(); + 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)); + 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())); + 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)); + 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 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, task.attemptId()))).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(); + 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, + task.attemptId()))).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)); + 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()); + 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, + task.attemptId())); + assertNotNull(preview.approvalToken()); + + assertEquals("NODE_UNAVAILABLE", assertThrows(ValidationException.class, + () -> operations.createPreview(List.of("backend-a"), + 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)); + 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); + 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, firstTask.attemptId())); + read = operations.complete(read.operationId(), "backend-b", new ConfigurationTaskResult(second, true, "OK", + "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()); + + 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()); + } + + @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<>(); + 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) { + 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, + task.attemptId())); + } + + 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); + 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) { + 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, + task.attemptId())); + } + } + 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); + } + + @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"); + + 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, 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, secondTask.attemptId())); + + assertEquals(null, read.results().get("backend-a").configuration()); + 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; } + 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; } + } + + 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/domain/InMemoryNodeRegistryTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java new file mode 100644 index 00000000..3ffddacb --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java @@ -0,0 +1,216 @@ +package com.bencodez.votingplugin.control.domain; + +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 { + 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 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 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 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"))); + + 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()); + } + + @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); + 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 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, detectedPlugins); + } + + private PresenceSnapshot snapshot(long sequence, BackendServerIdentity... 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) { + return new BackendServerIdentity(id, id, true, true, 1); + } + + private static final class MutableClock extends Clock { + private 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; } + @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..293e6574 --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -0,0 +1,424 @@ +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.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; +import java.net.Socket; +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 java.util.Map; +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-000000000099"), "test", 1), + 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(); + json = new ObjectMapper(); + } + + @AfterEach void stop() { + if (server != null) { + server.close(); + } + } + + @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'")); + 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")); + 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")); + 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")); + 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("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")); + assertEquals(200, get("/app.css", null).statusCode()); + assertError(send("POST", "/", null, null), 405, "METHOD_NOT_ALLOWED"); + 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); + 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 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()); + 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/detectedPlugins").toString().contains("LuckPerms")); + 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," + + "\"attemptId\":\"" + previewTask.get("attemptId").asText() + "\"}"; + 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")); + 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 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()); + 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 { + 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 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 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")); + assertTrue(admission.acquire("attacker")); + assertFalse(admission.acquire("attacker")); + assertTrue(admission.acquire("owner")); + admission.release("attacker"); + assertTrue(admission.acquire("attacker")); + 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"))); + 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 = + 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"); + 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); + assertError(send("POST", "/api/v1/nodes/register", nested, nodeToken), 400, "MALFORMED_JSON"); + 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")) + .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 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("\"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"); + } + + @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()))); + } + + private String registration() { + return "{\"nodeId\":\"proxy-a\",\"sessionId\":\"" + SESSION + "\",\"displayName\":\"Proxy A\"," + + "\"platform\":\"VELOCITY\",\"pluginVersion\":\"7.1.2\",\"protocolVersion\":1," + + "\"capabilities\":[\"presence.snapshot\"],\"requiredCapabilities\":[]," + + "\"detectedPlugins\":[\"LuckPerms\",\"Essentials\"]}"; + } + + private HttpResponse get(String path, String token) throws Exception { + return send("GET", path, null, token); + } + + 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)); + headers.forEach(builder::header); + 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 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()); + 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(), response.body()); + assertEquals(code, json.readTree(response.body()).at("/error/code").asText(), response.body()); + } +}