Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ VotingPlugin Control is designed to manage and troubleshoot a VotingPlugin netwo

### Updates and deployment

- Upload a VotingPlugin JAR to Control.
- Upload a VotingPlugin JAR from a local/private client, or select the latest successful development build from the fixed bencodez.com Jenkins source.
- Verify uploaded JARs before they can be deployed.
- Stage an approved VotingPlugin JAR to supported nodes for the **next restart**.
- Verify the JAR again on each node before staging it.
Expand Down Expand Up @@ -142,6 +142,13 @@ report `development` rather than maintaining a second version literal.
| `CONTROL_LAUNCH_ID` | empty | Optional UUID echoed by health checks so a supervising VotingPlugin can verify ownership of the listener |
| `CONTROL_PARENT_PID` | empty | Optional supervising VotingPlugin process ID; hosted Control exits when that parent process ends |

JAR uploads through a reverse proxy require HTTPS: set `CONTROL_SECURE_COOKIE=true`, list only that proxy in
`CONTROL_TRUSTED_PROXY_ADDRESSES`, and configure it to overwrite (never append or pass through) `X-Forwarded-Proto`.
Plain HTTP uploads through a proxy are rejected because the original client address cannot be proven. Direct plain-HTTP
uploads remain available to loopback, link-local, and private-network clients.
Artifact ingestion and deployment creation are serialized. Concurrent requests return `409 ARTIFACT_LIFECYCLE_BUSY`;
retry after the active request completes.

The server also uses a bounded HTTP executor (8 active requests and a 32-request queue), a 4 MiB limit for generic JSON
requests, and a separate bounded streaming artifact-upload route with a 64 MiB maximum. JSON depth/string/number sizes
are bounded, password verification uses two workers with per-client admission, and invalid-authentication failures are
Expand Down Expand Up @@ -247,7 +254,8 @@ All errors have the stable form:
| `POST` | `/api/v1/nodes/{nodeId}/inspections/{inspectionId}/result` | matching node | Complete that inspection attempt |
| `GET`, `POST` | `/api/v1/snapshots` | admin or WebUI session; CSRF for POST | List summaries or save a named snapshot from a completed file read |
| `GET` | `/api/v1/snapshots/{snapshotId}` | admin or WebUI session | Load one durable snapshot's full redacted file content |
| `POST` | `/api/v1/artifacts/votingplugin` | admin or WebUI session + CSRF | Stream and verify one bounded VotingPlugin JAR into private content-addressed storage |
| `POST` | `/api/v1/artifacts/votingplugin` | admin or WebUI session + CSRF | Stream and verify one bounded VotingPlugin JAR into private content-addressed storage; plain-HTTP uploads require a local/private client address |
| `GET`, `POST` | `/api/v1/artifacts/votingplugin/jenkins` | admin or WebUI session; CSRF for POST | Inspect or download an exact successful build from the server-defined bencodez.com Jenkins job; arbitrary URLs are never accepted |
| `GET`, `POST` | `/api/v1/deployments` | admin or WebUI session; CSRF for POST | List deployment history or stage a verified artifact on explicit `plugin.deploy.v1` nodes |
| `GET` | `/api/v1/deployments/{deploymentId}` | admin or WebUI session | Read durable per-node staging state |
| `POST` | `/api/v1/deployments/{deploymentId}/retry` | admin or WebUI session + CSRF | Retry only failed, currently eligible nodes as a new operation |
Expand Down
17 changes: 14 additions & 3 deletions docs/control-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ authenticated, CSRF-protected endpoint and node capability checks as an external
| Network Doctor | Runs `diagnostics` (which includes the overview fields), combines node health with Control's current topology, and displays checks for connector, configuration, Votifier, vote sites, rewards, logging, and proxy topology | Read-only; “healthy” is bounded reported state, not a synthetic vote |
| Diagnostics download | Downloads the last Network Doctor result as local JSON | Redacted status bundle only; no raw configuration/logs/player records/infrastructure secrets |
| Activity | Loads the newest 50 live/recovered configuration operation views, labels phases, lineage, reload/rollback, resumes eligible guided preview approvals, and offers retry only when `retryable` | Recovered configuration history cannot be retried; approval is single-use and apply is CSRF-protected; proxy-method apply needs a new preview |
| Plugin update | Uploads one bounded JAR, shows the deployment-capable subset, and stages it on those nodes | SHA-256 and JAR identity are verified; session/attempt leases authorize downloads; Control never automatically reloads or restarts nodes; private storage is capped at 32 artifacts / 512 MiB and evicts only artifacts not referenced by retained deployment history |
| Plugin update | Uploads one bounded local JAR or downloads one exact successful build from the fixed bencodez.com Jenkins job, shows the deployment-capable subset, and stages it on those nodes | SHA-256 and JAR identity are verified; session/attempt leases authorize downloads; Control never automatically reloads or restarts nodes; private storage is capped at 32 artifacts / 512 MiB and evicts only artifacts not referenced by retained deployment history |
| Fast file reads | Caches a successful file read for 30 seconds by node ID, node session, and file | Browser memory only; cleared on logout and successful relevant writes; session binding prevents reuse after reconnect |
| Full-YAML drafts | Keeps unsaved editor contents during a registry refresh | A dirty draft is bound to its source node, session, and file; it cannot preview or apply after that session changes. The operator must explicitly confirm a current-file read/reload, which discards the retained draft and rebinds the editor. |
| Full-YAML workbench | Shows the exact source node, file and retained revision, synchronized line numbers, document size, bounded in-document search, cursor position, and confirmed/unsaved state | Search and editor state remain browser-memory only; the workbench does not parse, normalize, save, or bypass the connector's redaction and preview validation |
Expand Down Expand Up @@ -495,7 +495,8 @@ node resources require the bearer credential bound to the path node ID.
| `POST` | `/api/v1/operations/{operationId}/retry` | admin/browser + CSRF | Reissue safe failed work as a new operation |
| `POST` | `/api/v1/nodes/{nodeId}/operations` | matching node | Claim one configuration task or `204` |
| `POST` | `/api/v1/nodes/{nodeId}/operations/{operationId}/result` | matching node | Complete one claimed configuration task |
| `POST` | `/api/v1/artifacts/votingplugin` | admin/browser + CSRF | Stream, hash, inspect, and atomically retain one VotingPlugin JAR (64 MiB maximum) |
| `POST` | `/api/v1/artifacts/votingplugin` | admin/browser + CSRF | Stream, hash, inspect, and atomically retain one VotingPlugin JAR (64 MiB maximum); plain-HTTP uploads require a literal local/private client address |
| `GET`, `POST` | `/api/v1/artifacts/votingplugin/jenkins` | admin/browser; CSRF for POST | Inspect or download an exact successful build from the fixed bencodez.com Jenkins job; no client URL is accepted |
| `GET`, `POST` | `/api/v1/deployments` | admin/browser; CSRF for POST | List durable staging operations or target an exact verified artifact |
| `GET` | `/api/v1/deployments/{deploymentId}` | admin/browser | Read per-node staging state |
| `POST` | `/api/v1/deployments/{deploymentId}/retry` | admin/browser + CSRF | Create a new operation for currently eligible failed targets only |
Expand All @@ -521,10 +522,20 @@ Control assumes an authenticated administrator may make intended changes, but it
their clocks, returned strings, or durable files. Authentication does not encrypt traffic; use HTTPS or a trusted private
tunnel/network outside loopback.

Direct JAR uploads over plain HTTP require a local/private socket peer. Uploads through a reverse proxy require HTTPS,
the peer in `CONTROL_TRUSTED_PROXY_ADDRESSES`, `CONTROL_SECURE_COOKIE=true`, and a proxy-overwritten
`X-Forwarded-Proto`. Plain HTTP through a proxy is rejected because forwarded client identity cannot safely prove the
upload originated from a private address.

Artifact ingestion and deployment creation share one admission slot to protect retained artifact references during
eviction. Concurrent uploads, Jenkins ingestion, or deployment creation return `409 ARTIFACT_LIFECYCLE_BUSY` immediately;
retry after the active request completes. Jenkins reserves the slot before opening its remote download, and admitted
artifact body transfers retain the 120-second deadline.

| Boundary | Limit/behavior |
| --- | --- |
| Generic JSON request | 4 MiB; bounded Jackson depth/string/number constraints; duplicate and trailing JSON rejected |
| VotingPlugin artifact upload | Separate bounded streaming route; 64 MiB maximum |
| VotingPlugin artifact ingestion | Local/private plain-HTTP upload or fixed HTTPS bencodez.com Jenkins source; separate bounded streaming route; 64 MiB maximum; SHA-256 established before publication |
| HTTP execution | 8 active request workers plus queue of 32; bounded request/response time |
| Browser sessions | 100; 30-minute idle and 8-hour absolute expiry; HttpOnly, SameSite=Strict cookie |
| Node operation targets | 1–100 distinct online capable nodes |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package com.bencodez.votingplugin.control.artifact;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.io.InputStream;
import java.io.FilterInputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;

/** Fixed, read-only source for public VotingPlugin development artifacts. */
public final class JenkinsVotingPluginSource implements AutoCloseable {
static final URI DEFAULT_JOB = URI.create("https://bencodez.com/job/VotingPlugin/");
private static final int MAX_METADATA_BYTES = 256 * 1024;
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);
private static final Pattern ARTIFACT_NAME = Pattern.compile("VotingPlugin-[0-9][A-Za-z0-9._-]*\\.jar");

private final URI job;
private final HttpClient client;
private final ObjectMapper json;
private final ScheduledExecutorService metadataTimeouts;
private final Duration metadataTimeout;

public JenkinsVotingPluginSource() {
this(DEFAULT_JOB, HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NEVER).build());
}

JenkinsVotingPluginSource(URI job, HttpClient client) {
this(job, client, REQUEST_TIMEOUT);
}

JenkinsVotingPluginSource(URI job, HttpClient client, Duration metadataTimeout) {
this.job = requireJob(job);
this.client = client;
if (metadataTimeout == null || metadataTimeout.toMillis() < 1) {
throw new IllegalArgumentException("Invalid Jenkins metadata timeout");
}
this.metadataTimeout = metadataTimeout;
metadataTimeouts = Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(runnable, "votingplugin-control-jenkins-timeout");
thread.setDaemon(true);
return thread;
});
json = new ObjectMapper().enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
}

public Build latestSuccessful() throws IOException {
return readBuild(job.resolve("lastSuccessfulBuild/api/json"), null);
}

public Download open(int buildNumber) throws IOException {
if (buildNumber < 1) throw unavailable();
Build build = readBuild(job.resolve(buildNumber + "/api/json"), buildNumber);
URI artifact = job.resolve(buildNumber + "/artifact/" + build.relativePath());
HttpRequest request = request(artifact).build();
HttpResponse<InputStream> response = send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200 || response.headers().firstValueAsLong("Content-Length")
.orElse(-1L) > ArtifactStore.MAX_UPLOAD_BYTES) {
response.body().close();
throw unavailable();
}
return new Download(build, response.body());
}

private Build readBuild(URI uri, Integer expectedNumber) throws IOException {
HttpResponse<InputStream> response = send(request(uri).header("Accept", "application/json").build(),
HttpResponse.BodyHandlers.ofInputStream());
try (InputStream body = response.body()) {
if (response.statusCode() != 200) throw unavailable();
ScheduledFuture<?> timeout = metadataTimeouts.schedule(() -> closeQuietly(body),
metadataTimeout.toMillis(), TimeUnit.MILLISECONDS);
byte[] content;
try {
content = body.readNBytes(MAX_METADATA_BYTES + 1);
} finally {
timeout.cancel(false);
}
if (content.length > MAX_METADATA_BYTES) throw unavailable();
JsonNode root = json.readTree(content);
int number = root.path("number").asInt(-1);
String result = root.path("result").asText("");
boolean building = root.path("building").asBoolean(true);
long timestamp = root.path("timestamp").asLong(-1);
if (number < 1 || expectedNumber != null && number != expectedNumber
|| building || !"SUCCESS".equals(result) || timestamp < 0) throw unavailable();
JsonNode artifactList = root.path("artifacts");
if (!artifactList.isArray() || artifactList.isEmpty() || artifactList.size() > 32) throw unavailable();
JsonNode artifact = null;
for (JsonNode candidate : artifactList) {
String candidateName = candidate.path("fileName").asText("");
String candidatePath = candidate.path("relativePath").asText("");
if (isExpectedArtifact(candidateName, candidatePath)) {
if (artifact != null) throw unavailable();
artifact = candidate;
}
}
if (artifact == null) throw unavailable();
String fileName = artifact.path("fileName").asText("");
String relativePath = artifact.path("relativePath").asText("");
if (!isExpectedArtifact(fileName, relativePath)) throw unavailable();
return new Build(number, timestamp, fileName, relativePath);
} catch (RuntimeException failure) {
throw unavailable();
}
}

private static boolean isExpectedArtifact(String fileName, String relativePath) {
return ARTIFACT_NAME.matcher(fileName).matches()
&& relativePath.equals("VotingPlugin/target/" + fileName);
}

private HttpRequest.Builder request(URI uri) {
if (!sameOrigin(uri)) throw new IllegalStateException("Jenkins source escaped its fixed origin");
return HttpRequest.newBuilder(uri).timeout(REQUEST_TIMEOUT)
.header("User-Agent", "VotingPlugin-Control/1.0");
}

private boolean sameOrigin(URI uri) {
return job.getScheme().equalsIgnoreCase(uri.getScheme()) && job.getHost().equalsIgnoreCase(uri.getHost())
&& effectivePort(job) == effectivePort(uri) && uri.getRawUserInfo() == null;
}

private static int effectivePort(URI uri) {
return uri.getPort() >= 0 ? uri.getPort() : "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
}

private static URI requireJob(URI job) {
boolean testLoopback = job != null && "http".equalsIgnoreCase(job.getScheme())
&& ("127.0.0.1".equals(job.getHost()) || "::1".equals(job.getHost()));
if (job == null || !("https".equalsIgnoreCase(job.getScheme()) || testLoopback) || job.getHost() == null
|| job.getRawUserInfo() != null || job.getRawQuery() != null || job.getRawFragment() != null
|| !job.getPath().endsWith("/")) throw new IllegalArgumentException("Invalid Jenkins job origin");
return job;
}

private <T> HttpResponse<T> send(HttpRequest request, HttpResponse.BodyHandler<T> handler) throws IOException {
try {
return client.send(request, handler);
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw unavailable();
} catch (RuntimeException failure) {
throw unavailable();
}
}

private static IOException unavailable() {
return new IOException("VotingPlugin development build is unavailable");
}

private static void closeQuietly(InputStream input) {
try {
input.close();
} catch (IOException ignored) {
// Closing is the deadline signal; the blocked reader reports the failure.
}
}

@Override public void close() {
metadataTimeouts.shutdownNow();
}

public record Build(int buildNumber, long timestamp, String fileName, String relativePath) { }

public static final class Download implements AutoCloseable {
private final Build build;
private final SourceInputStream body;

private Download(Build build, InputStream body) {
this.build = build;
this.body = new SourceInputStream(body);
}

public Build build() { return build; }
public InputStream body() { return body; }
public boolean sourceReadFailed() { return body.failure != null; }
@Override public void close() throws IOException { body.close(); }
}

private static final class SourceInputStream extends FilterInputStream {
private IOException failure;

private SourceInputStream(InputStream input) { super(input); }

@Override public int read() throws IOException {
try {
return super.read();
} catch (IOException sourceFailure) {
failure = sourceFailure;
throw sourceFailure;
}
}

@Override public int read(byte[] bytes, int offset, int length) throws IOException {
try {
return super.read(bytes, offset, length);
} catch (IOException sourceFailure) {
failure = sourceFailure;
throw sourceFailure;
}
}
}
}
Loading
Loading