From 61512fd58900bd6726f1dbeb3cd07b3b1b1c6802 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:59:07 -0600 Subject: [PATCH 1/3] Add verified Jenkins deployment source --- README.md | 10 +- docs/control-management.md | 12 +- .../artifact/JenkinsVotingPluginSource.java | 176 ++++++++++++++++++ .../control/http/ControlHttpServer.java | 115 ++++++++++++ src/main/resources/web/app.js | 86 ++++++--- src/main/resources/web/index.html | 4 +- .../JenkinsVotingPluginSourceTest.java | 141 ++++++++++++++ .../control/http/ControlHttpServerTest.java | 34 +++- 8 files changed, 543 insertions(+), 35 deletions(-) create mode 100644 src/main/java/com/bencodez/votingplugin/control/artifact/JenkinsVotingPluginSource.java create mode 100644 src/test/java/com/bencodez/votingplugin/control/artifact/JenkinsVotingPluginSourceTest.java diff --git a/README.md b/README.md index badb32a..2861a9a 100644 --- a/README.md +++ b/README.md @@ -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. @@ -142,6 +142,11 @@ 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. + 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 @@ -247,7 +252,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 | diff --git a/docs/control-management.md b/docs/control-management.md index af96135..93e64b6 100644 --- a/docs/control-management.md +++ b/docs/control-management.md @@ -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 | @@ -484,7 +484,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 | @@ -510,10 +511,15 @@ 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. + | 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 | diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/JenkinsVotingPluginSource.java b/src/main/java/com/bencodez/votingplugin/control/artifact/JenkinsVotingPluginSource.java new file mode 100644 index 0000000..9563b44 --- /dev/null +++ b/src/main/java/com/bencodez/votingplugin/control/artifact/JenkinsVotingPluginSource.java @@ -0,0 +1,176 @@ +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.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]+\\.jar"); + private static final Pattern ARTIFACT_PATH = Pattern.compile("VotingPlugin/target/VotingPlugin-[0-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 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 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 (candidateName.equals("VotingPlugin-" + number + ".jar") + && candidatePath.equals("VotingPlugin/target/" + candidateName)) { + 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 (!ARTIFACT_NAME.matcher(fileName).matches() || !ARTIFACT_PATH.matcher(relativePath).matches() + || !fileName.equals("VotingPlugin-" + number + ".jar") + || !relativePath.equals("VotingPlugin/target/" + fileName)) throw unavailable(); + return new Build(number, timestamp, fileName, relativePath); + } catch (RuntimeException failure) { + throw unavailable(); + } + } + + 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 HttpResponse send(HttpRequest request, HttpResponse.BodyHandler 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 record Download(Build build, InputStream body) implements AutoCloseable { + @Override public void close() throws IOException { body.close(); } + } +} diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 3780a3c..aab4374 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -3,6 +3,7 @@ import com.bencodez.votingplugin.control.auth.CredentialStore; import com.bencodez.votingplugin.control.auth.WebSessionStore; import com.bencodez.votingplugin.control.artifact.ArtifactStore; +import com.bencodez.votingplugin.control.artifact.JenkinsVotingPluginSource; import com.bencodez.votingplugin.control.artifact.ArtifactStore.ArtifactException; import com.bencodez.votingplugin.control.domain.NodeRegistry; import com.bencodez.votingplugin.control.domain.ConfigurationOperations; @@ -80,6 +81,7 @@ public final class ControlHttpServer implements AutoCloseable { private static final String INSPECTIONS = "/api/v1/inspections"; private static final String SNAPSHOTS = "/api/v1/snapshots"; private static final String ARTIFACTS = "/api/v1/artifacts/votingplugin"; + private static final String JENKINS_ARTIFACT = ARTIFACTS + "/jenkins"; private static final String DEPLOYMENTS = "/api/v1/deployments"; private static final String AUTH_LOGIN = "/api/v1/auth/login"; private static final String AUTH_SESSION = "/api/v1/auth/session"; @@ -116,6 +118,7 @@ public final class ControlHttpServer implements AutoCloseable { private final InspectionOperations inspectionOperations; private final ConfigurationSnapshots configurationSnapshots; private final ArtifactStore artifactStore; + private final JenkinsVotingPluginSource jenkinsSource = new JenkinsVotingPluginSource(); private final DeploymentOperations deploymentOperations; private final Object deploymentArtifactLifecycle = new Object(); private final ThreadPoolExecutor executor; @@ -299,6 +302,7 @@ public void close() { executor.shutdownNow(); passwordExecutor.shutdownNow(); artifactTransferTimeouts.shutdownNow(); + jenkinsSource.close(); try { executor.awaitTermination(2, TimeUnit.SECONDS); passwordExecutor.awaitTermination(2, TimeUnit.SECONDS); @@ -334,6 +338,7 @@ private void handle(HttpExchange exchange) throws IOException { "SETUP_COMPLETE", "OPERATION_INCOMPLETE", "PREVIEW_REQUIRED", "RETRY_REQUIRES_INPUT", "TARGET_CHANGED", "OPERATION_CONFLICT" -> 409; case "UNSUPPORTED_MEDIA_TYPE" -> 415; + case "LOCAL_NETWORK_REQUIRED" -> 403; default -> 400; }; error(exchange, status, e.code(), e.getMessage(), e.details()); @@ -348,6 +353,9 @@ private void handle(HttpExchange exchange) throws IOException { error(exchange, 503, "SNAPSHOT_STORE_UNAVAILABLE", "Configuration snapshot storage is unavailable", List.of()); } catch (ArtifactException e) { error(exchange, 400, "ARTIFACT_REJECTED", "VotingPlugin artifact was rejected", List.of()); + } catch (JenkinsSourceException e) { + error(exchange, 502, "BUILD_SOURCE_UNAVAILABLE", + "The VotingPlugin development build source is unavailable", List.of()); } catch (IllegalArgumentException e) { error(exchange, 400, "VALIDATION_ERROR", "Request validation failed", List.of()); } catch (ResponseCompleteException ignored) { @@ -521,6 +529,7 @@ private void route(HttpExchange exchange) throws IOException { if (ARTIFACTS.equals(path)) { requireMethod(exchange, "POST"); authenticateAdmin(exchange, true); + requireSafeArtifactUploadTransport(exchange); requireArtifactMediaType(exchange); requireBodyWithin(exchange, ArtifactStore.MAX_UPLOAD_BYTES); String filename = requiredHeader(exchange, "X-Filename"); @@ -535,6 +544,42 @@ private void route(HttpExchange exchange) throws IOException { "size", artifact.size(), "fileName", artifact.displayFilename())); return; } + if (JENKINS_ARTIFACT.equals(path)) { + if ("GET".equals(exchange.getRequestMethod())) { + authenticateAdmin(exchange, false); + try { + send(exchange, 200, jenkinsSource.latestSuccessful()); + } catch (IOException failure) { + throw new JenkinsSourceException(failure); + } + return; + } + if ("POST".equals(exchange.getRequestMethod())) { + authenticateAdmin(exchange, true); + JenkinsArtifactRequest request = read(exchange, JenkinsArtifactRequest.class); + requireRequest(request); + try (JenkinsVotingPluginSource.Download download = jenkinsSource.open(request.buildNumber())) { + ArtifactStore.Artifact artifact; + synchronized (deploymentArtifactLifecycle) { + artifact = withDownloadDeadline(download, () -> artifactStore.upload(download.body(), + download.build().fileName(), null, deploymentOperations.referencedArtifactIds())); + } + send(exchange, 201, Map.of("artifactId", artifact.artifactId(), + "sha256", artifact.artifactId(), "size", artifact.size(), + "fileName", artifact.displayFilename(), "buildNumber", download.build().buildNumber(), + "timestamp", download.build().timestamp())); + } catch (ArtifactException failure) { + throw failure; + } catch (IOException failure) { + throw new JenkinsSourceException(failure); + } + return; + } + exchange.getResponseHeaders().set("Allow", "GET, POST"); + error(exchange, 405, "METHOD_NOT_ALLOWED", "Method is not allowed", + List.of("allowed=GET", "allowed=POST")); + throw new ResponseCompleteException(); + } if (DEPLOYMENTS.equals(path)) { if ("GET".equals(exchange.getRequestMethod())) { authenticateAdmin(exchange, false); @@ -1072,6 +1117,21 @@ private T withArtifactTransferDeadline(HttpExchange exchange, IoSupplier } } + private T withDownloadDeadline(AutoCloseable download, IoSupplier transfer) throws IOException { + ScheduledFuture timeout = artifactTransferTimeouts.schedule(() -> { + try { + download.close(); + } catch (Exception ignored) { + // Closing is the timeout signal; the transfer reports the resulting I/O failure. + } + }, ARTIFACT_TRANSFER_TIMEOUT_SECONDS, TimeUnit.SECONDS); + try { + return transfer.get(); + } finally { + timeout.cancel(false); + } + } + private static void requireRequest(Object request) { if (request == null) { throw new ValidationException("VALIDATION_ERROR", "Request validation failed", @@ -1133,6 +1193,52 @@ private String passwordClient(HttpExchange exchange) { return forwardedPasswordClient(peer, singleHeader(exchange, "X-Forwarded-For"), trustedProxyAddresses); } + private void requireSafeArtifactUploadTransport(HttpExchange exchange) { + if (exchange instanceof HttpsExchange) return; + InetSocketAddress remote = exchange.getRemoteAddress(); + if (remote == null) throw localUploadRequired(); + String peer = remote.getAddress() == null ? remote.getHostString() : remote.getAddress().getHostAddress(); + if (remote.getAddress() != null && !trustedProxyAddresses.contains(peer) + && isLocalNetworkAddress(remote.getAddress())) return; + if (!artifactUploadTransportAllowed(peer, singleHeader(exchange, "X-Forwarded-For"), + singleHeader(exchange, "X-Forwarded-Proto"), trustedProxyAddresses, secureCookies)) { + throw localUploadRequired(); + } + } + + static boolean artifactUploadTransportAllowed(String peer, String forwardedFor, String forwardedProto, + Set trustedProxies, boolean trustForwardedHttps) { + boolean trustedProxy = trustedProxies.contains(peer); + if (trustedProxy) { + return trustForwardedHttps && "https".equalsIgnoreCase(forwardedProto); + } + String client = forwardedPasswordClient(peer, forwardedFor, trustedProxies); + String canonical = canonicalIpLiteral(client); + return canonical != null && isLocalNetworkAddress(canonical); + } + + static boolean isLocalNetworkAddress(String literal) { + String canonical = canonicalIpLiteral(literal); + if (canonical == null) return false; + try { + return isLocalNetworkAddress(InetAddress.getByName(canonical)); + } catch (IOException failure) { + return false; + } + } + + static boolean isLocalNetworkAddress(InetAddress address) { + byte[] bytes = address.getAddress(); + boolean uniqueLocalV6 = bytes.length == 16 && (bytes[0] & 0xfe) == 0xfc; + return address.isLoopbackAddress() || address.isLinkLocalAddress() + || address.isSiteLocalAddress() || uniqueLocalV6; + } + + private static ValidationException localUploadRequired() { + return new ValidationException("LOCAL_NETWORK_REQUIRED", + "Direct JAR uploads over HTTP require a local or private-network client address", List.of()); + } + static String forwardedPasswordClient(String peer, String forwardedFor, Set trustedProxies) { if (!trustedProxies.contains(peer) || forwardedFor == null || forwardedFor.isBlank()) return peer; String selected = peer; @@ -1334,11 +1440,20 @@ private static final class SnapshotStoreException extends RuntimeException { private SnapshotStoreException(IOException cause) { super(cause); } } + private static final class JenkinsSourceException extends RuntimeException { + private JenkinsSourceException(IOException cause) { super(cause); } + } + private record WebResource(String classpath, String contentType) { } private record PasswordRequest(String password) { } private record SetupRequest(String setupCode, String password) { } private record EnrollmentRequest(String nodeId) { } private record SnapshotRequest(String name, UUID operationId) { } + private record JenkinsArtifactRequest(int buildNumber) { + private JenkinsArtifactRequest { + if (buildNumber < 1) throw new IllegalArgumentException("buildNumber must be positive"); + } + } private record DeploymentClaim(UUID sessionId) { } record BackendPage(List items, int backendItemsReturned, boolean backendItemsTruncated, List backendItemsTruncatedNodeIds) { } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 7ba0d13..949c186 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -67,6 +67,7 @@ const nextPage = document.querySelector('#next-page'); const pageNumber = document.querySelector('#page-number'); const deploymentJar = document.querySelector('#deployment-jar'); const deployPlugin = document.querySelector('#deploy-plugin'); +const deployJenkins = document.querySelector('#deploy-jenkins'); const deploymentEligibility = document.querySelector('#deployment-eligibility'); const deploymentStatus = document.querySelector('#deployment-status'); const sendAll = document.querySelector('#send-all'); @@ -3216,6 +3217,8 @@ function renderDeploymentEligibility() { deploymentEligibility.className = `pill ${eligible.length ? 'online' : connected.length ? 'warning' : 'neutral'}`; deployPlugin.disabled = !authenticated || logoutInFlight || deploymentInFlight || !deploymentJar.files?.length || eligible.length === 0 || batches > MAX_DEPLOYMENT_BATCHES; + deployJenkins.disabled = !authenticated || logoutInFlight || deploymentInFlight + || eligible.length === 0 || batches > MAX_DEPLOYMENT_BATCHES; } function selectNodePage(offset) { @@ -5876,10 +5879,10 @@ document.querySelector('#home-restore').addEventListener('click', () => { document.querySelector('#choose-global').addEventListener('click', enterGlobalWorkspace); document.querySelector('#scope-overview').addEventListener('click', openScopeOverview); deploymentJar.addEventListener('change', renderDeploymentEligibility); -deployPlugin.addEventListener('click', async () => { - const file = deploymentJar.files?.[0]; + +async function stageVerifiedArtifact(artifact, confirmation) { const eligible = deploymentTargets(); - if (!authenticated || logoutInFlight || !file || !eligible.length || deploymentInFlight) return; + if (!authenticated || logoutInFlight || !eligible.length || deploymentInFlight) return; const batches = []; for (let offset = 0; offset < eligible.length; offset += MAX_OPERATION_TARGETS) { batches.push(eligible.slice(offset, offset + MAX_OPERATION_TARGETS)); @@ -5888,17 +5891,13 @@ deployPlugin.addEventListener('click', async () => { text(deploymentStatus, `This deployment exceeds the ${MAX_DEPLOYMENT_BATCHES}-batch safety limit.`); return; } - if (!file.name.toLowerCase().endsWith('.jar') || file.size < 1 || file.size > 64 * 1024 * 1024) { - text(deploymentStatus, 'Choose a non-empty VotingPlugin JAR no larger than 64 MiB.'); - return; - } const ineligible = allNodeItems.filter(node => node.online && !node.acceptedCapabilities.includes('plugin.deploy.v1')).map(node => node.displayName); - const confirmation = `Upload ${file.name} (${file.size.toLocaleString()} bytes) and stage it on ` - + `${eligible.length} deployment-capable node(s)? Servers will require a restart. Automatic restart is disabled.` + const prompt = `${confirmation} Stage it on ${eligible.length} deployment-capable node(s)?` + + ' Servers will require a restart. Automatic restart is disabled.' + (batches.length > 1 ? ` Control will use ${batches.length} bounded operations.` : '') + (ineligible.length ? ` Older/incompatible nodes excluded: ${ineligible.join(', ')}.` : ''); - if (!window.confirm(confirmation)) return; + if (!window.confirm(prompt)) return; const deploymentRun = ++deploymentRunGeneration; deploymentInFlight = true; renderDeploymentEligibility(); @@ -5907,27 +5906,17 @@ deployPlugin.addEventListener('click', async () => { const completedOperations = []; const unavailableBatchNodes = []; try { - text(deploymentStatus, 'Calculating SHA-256 locally…'); - const sha256 = await deploymentFileSha256(file); + const verified = await artifact(generation); if (generation !== authenticationGeneration) { - throw new Error('Authentication changed before the deployment upload started.'); + throw new Error('Authentication changed before the verified artifact was staged.'); } - text(deploymentStatus, sha256 ? `Uploading artifact ${sha256.slice(0, 12)} for server verification…` - : 'Uploading artifact for bounded server-side SHA-256 verification…'); - const uploadHeaders = {'Content-Type': 'application/java-archive', 'X-Filename': file.name}; - if (sha256) uploadHeaders['X-Artifact-SHA256'] = sha256; - const artifact = await authorized('/api/v1/artifacts/votingplugin', { - method: 'POST', headers: uploadHeaders, body: file - }); - if (sha256 && artifact.sha256 !== sha256 || artifact.size !== file.size) { - throw new Error('Control returned artifact metadata that does not match the selected JAR.'); + if (!verified?.artifactId || verified.artifactId !== verified.sha256 + || !Number.isSafeInteger(verified.size) || verified.size < 1 || verified.size > 64 * 1024 * 1024) { + throw new Error('Control returned invalid verified artifact metadata.'); } - const verifiedSha256 = artifact.sha256; + text(deploymentStatus, `Verified ${verified.fileName || 'VotingPlugin JAR'} · SHA-256 ${verified.sha256}. Staging…`); const operations = []; for (const batch of batches) { - if (generation !== authenticationGeneration) { - throw new Error('Authentication changed before every deployment batch was submitted.'); - } let remaining = batch.map(node => node.nodeId); for (let attempt = 0; remaining.length > 0 && attempt < MAX_OPERATION_TARGETS; attempt++) { if (generation !== authenticationGeneration) { @@ -5936,7 +5925,7 @@ deployPlugin.addEventListener('click', async () => { try { const operation = await authorized('/api/v1/deployments', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ - artifactId: artifact.artifactId, sha256: verifiedSha256, size: file.size, + artifactId: verified.artifactId, sha256: verified.sha256, size: verified.size, nodeIds: remaining }) }); @@ -5986,6 +5975,49 @@ deployPlugin.addEventListener('click', async () => { renderDeploymentEligibility(); } } +} + +deployPlugin.addEventListener('click', async () => { + const file = deploymentJar.files?.[0]; + if (!file || !file.name.toLowerCase().endsWith('.jar') || file.size < 1 || file.size > 64 * 1024 * 1024) { + text(deploymentStatus, 'Choose a non-empty VotingPlugin JAR no larger than 64 MiB.'); + return; + } + await stageVerifiedArtifact(async generation => { + text(deploymentStatus, 'Calculating SHA-256 locally…'); + const sha256 = await deploymentFileSha256(file); + if (generation !== authenticationGeneration) throw new Error('Authentication changed before upload.'); + text(deploymentStatus, sha256 ? `Uploading artifact ${sha256.slice(0, 12)} for server verification…` + : 'Uploading artifact for bounded server-side SHA-256 verification…'); + const headers = {'Content-Type': 'application/java-archive', 'X-Filename': file.name}; + if (sha256) headers['X-Artifact-SHA256'] = sha256; + const uploaded = await authorized('/api/v1/artifacts/votingplugin', {method: 'POST', headers, body: file}); + if (sha256 && uploaded.sha256 !== sha256 || uploaded.size !== file.size) { + throw new Error('Control returned artifact metadata that does not match the selected JAR.'); + } + return uploaded; + }, `Upload ${file.name} (${file.size.toLocaleString()} bytes).`); +}); + +deployJenkins.addEventListener('click', async () => { + if (!authenticated || logoutInFlight || deploymentInFlight) return; + try { + text(deploymentStatus, 'Checking the latest successful bencodez.com Jenkins build…'); + const build = await authorized('/api/v1/artifacts/votingplugin/jenkins'); + await stageVerifiedArtifact(async generation => { + text(deploymentStatus, `Downloading VotingPlugin Jenkins build ${build.buildNumber} for SHA-256 verification…`); + const downloaded = await authorized('/api/v1/artifacts/votingplugin/jenkins', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({buildNumber: build.buildNumber}) + }); + if (generation !== authenticationGeneration || downloaded.buildNumber !== build.buildNumber) { + throw new Error('The selected Jenkins build changed before staging.'); + } + return downloaded; + }, `Download successful Jenkins build ${build.buildNumber} (${build.fileName}) from bencodez.com.`); + } catch (error) { + if (authenticated) text(deploymentStatus, error.message); + } }); tabButtons.forEach(button => button.addEventListener('click', () => { if (button.dataset.configShortcut) setConfigView(button.dataset.configShortcut); diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 0197976..e955899 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -209,11 +209,11 @@

Servers

-

Verified update staging

Update VotingPlugin

Upload one VotingPlugin JAR, verify its SHA-256, then stage it on every connected deployment-capable node. Servers are never restarted automatically.

+

Verified update staging

Update VotingPlugin

Upload a local VotingPlugin JAR or download the latest successful development build from bencodez.com Jenkins. Control verifies SHA-256 and every node verifies it again before staging. Servers are never restarted automatically.

No eligible nodes
-
+
Nodes running an older connector remain connected but require one manual update before web deployment is available.
diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/JenkinsVotingPluginSourceTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/JenkinsVotingPluginSourceTest.java new file mode 100644 index 0000000..9d7b4c2 --- /dev/null +++ b/src/test/java/com/bencodez/votingplugin/control/artifact/JenkinsVotingPluginSourceTest.java @@ -0,0 +1,141 @@ +package com.bencodez.votingplugin.control.artifact; + +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayOutputStream; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.HexFormat; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +class JenkinsVotingPluginSourceTest { + @TempDir Path directory; + private HttpServer server; + private final List sources = new ArrayList<>(); + + @AfterEach void stop() { + sources.forEach(JenkinsVotingPluginSource::close); + if (server != null) server.stop(0); + } + + @Test void readsFixedSuccessfulBuildAndDownloadsExactArtifact() throws Exception { + byte[] jar = votingPluginJar(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + respond("/job/VotingPlugin/lastSuccessfulBuild/api/json", metadata(2393)); + respond("/job/VotingPlugin/2393/api/json", metadata(2393)); + respond("/job/VotingPlugin/2393/artifact/VotingPlugin/target/VotingPlugin-2393.jar", jar, + "application/java-archive"); + server.start(); + + JenkinsVotingPluginSource source = source(); + assertEquals(2393, source.latestSuccessful().buildNumber()); + try (JenkinsVotingPluginSource.Download download = source.open(2393)) { + assertEquals("VotingPlugin-2393.jar", download.build().fileName()); + ArtifactStore.Artifact artifact = new ArtifactStore(directory).upload(download.body(), + download.build().fileName(), null); + assertEquals(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(jar)), + artifact.artifactId()); + assertEquals(jar.length, artifact.size()); + } + } + + @Test void rejectsFailedMismatchedAndRedirectedBuilds() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + respond("/job/VotingPlugin/lastSuccessfulBuild/api/json", metadata(2393).replace("SUCCESS", "FAILURE")); + respond("/job/VotingPlugin/2393/api/json", metadata(2394)); + server.createContext("/job/VotingPlugin/2394/api/json", exchange -> { + exchange.getResponseHeaders().set("Location", "https://example.test/attacker"); + exchange.sendResponseHeaders(302, -1); + exchange.close(); + }); + server.start(); + + JenkinsVotingPluginSource source = source(); + assertThrows(java.io.IOException.class, source::latestSuccessful); + assertThrows(java.io.IOException.class, () -> source.open(2393)); + assertThrows(java.io.IOException.class, () -> source.open(2394)); + } + + @Test void boundsStalledMetadataBodyReads() throws Exception { + CountDownLatch headersSent = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/job/VotingPlugin/lastSuccessfulBuild/api/json", exchange -> { + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, 1024); + headersSent.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } finally { + exchange.close(); + } + }); + server.start(); + + JenkinsVotingPluginSource source = source(Duration.ofMillis(100)); + try { + assertTimeoutPreemptively(Duration.ofSeconds(2), () -> + assertThrows(java.io.IOException.class, source::latestSuccessful)); + assertTrue(headersSent.await(1, TimeUnit.SECONDS)); + } finally { + release.countDown(); + } + } + + private JenkinsVotingPluginSource source() { + return source(Duration.ofSeconds(30)); + } + + private JenkinsVotingPluginSource source(Duration timeout) { + URI job = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/job/VotingPlugin/"); + JenkinsVotingPluginSource source = new JenkinsVotingPluginSource(job, HttpClient.newHttpClient(), timeout); + sources.add(source); + return source; + } + + private void respond(String path, String body) { + respond(path, body.getBytes(StandardCharsets.UTF_8), "application/json"); + } + + private void respond(String path, byte[] body, String contentType) { + server.createContext(path, exchange -> { + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + } + + private static String metadata(int build) { + return """ + {"number":%d,"result":"SUCCESS","building":false,"timestamp":1790648737343, + "artifacts":[{"fileName":"VotingPlugin-%d.jar", + "relativePath":"VotingPlugin/target/VotingPlugin-%d.jar"}]} + """.formatted(build, build, build); + } + + private static byte[] votingPluginJar() throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bytes)) { + zip.putNextEntry(new ZipEntry("plugin.yml")); + zip.write("name: VotingPlugin\nmain: example.Main\nversion: test\n".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + return bytes.toByteArray(); + } +} diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 905df84..990239d 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -96,6 +96,7 @@ class ControlHttpServerTest { const deploymentJar = {files: [{}]}; const deploymentEligibility = {textContent: '', className: ''}; const deployPlugin = {disabled: false}; + const deployJenkins = {disabled: false}; function text(element, value) { element.textContent = value; return element; } let allNodeItems = []; """ + actualFunctions + """ @@ -202,6 +203,8 @@ private static void terminateProcess(Process process) { assertTrue(web.body().contains("id=\"quick-party-enabled\"")); assertTrue(web.body().contains("id=\"deployment-jar\"")); assertTrue(web.body().contains("Upload and stage on eligible servers")); + assertTrue(web.body().contains("id=\"deploy-jenkins\"")); + assertTrue(web.body().contains("Use latest Jenkins build")); assertTrue(web.headers().firstValue("Content-Security-Policy").orElseThrow().contains("default-src 'self'")); HttpResponse script = get("/app.js", null); assertEquals(200, script.statusCode()); @@ -249,7 +252,7 @@ private static void terminateProcess(Process process) { assertTrue(script.body().contains("authenticationGeneration")); assertTrue(script.body().contains("if (loginInFlight) return")); assertTrue(script.body().contains("logoutInFlight && path !== '/api/v1/auth/logout'")); - assertTrue(script.body().contains("!authenticated || logoutInFlight || !file")); + assertTrue(script.body().contains("!authenticated || logoutInFlight || !eligible.length || deploymentInFlight")); assertTrue(script.body().contains("const deploymentRun = ++deploymentRunGeneration;")); assertTrue(script.body().contains("if (deploymentRun === deploymentRunGeneration) {\n" + " deploymentInFlight = false;"), @@ -1535,6 +1538,35 @@ private static void terminateProcess(Process process) { java.util.Set.of("127.0.0.1"))); } + @Test void directHttpArtifactUploadsAreLimitedToLiteralLocalNetworkAddresses() throws Exception { + assertTrue(ControlHttpServer.isLocalNetworkAddress("127.0.0.1")); + assertTrue(ControlHttpServer.isLocalNetworkAddress("10.20.30.40")); + assertTrue(ControlHttpServer.isLocalNetworkAddress("172.31.4.5")); + assertTrue(ControlHttpServer.isLocalNetworkAddress("192.168.0.50")); + assertTrue(ControlHttpServer.isLocalNetworkAddress("fd00::50")); + assertTrue(ControlHttpServer.isLocalNetworkAddress(java.net.InetAddress.getByName("fe80::1"))); + assertFalse(ControlHttpServer.isLocalNetworkAddress("8.8.8.8")); + assertFalse(ControlHttpServer.isLocalNetworkAddress("192.0.2.10")); + assertFalse(ControlHttpServer.isLocalNetworkAddress("localhost")); + assertFalse(ControlHttpServer.isLocalNetworkAddress("control.example.test")); + assertTrue(ControlHttpServer.artifactUploadTransportAllowed("192.168.0.20", null, null, + java.util.Set.of(), false)); + assertFalse(ControlHttpServer.artifactUploadTransportAllowed("203.0.113.20", null, null, + java.util.Set.of(), false)); + assertTrue(ControlHttpServer.artifactUploadTransportAllowed("127.0.0.1", "203.0.113.20", "https", + java.util.Set.of("127.0.0.1"), true)); + assertFalse(ControlHttpServer.artifactUploadTransportAllowed("127.0.0.1", "203.0.113.20", "https", + java.util.Set.of("127.0.0.1"), false), "forwarded HTTPS requires explicit secure-proxy mode"); + assertFalse(ControlHttpServer.artifactUploadTransportAllowed("127.0.0.1", "192.168.0.20", "http", + java.util.Set.of("127.0.0.1"), false), "plain HTTP through a proxy cannot prove client identity"); + assertFalse(ControlHttpServer.artifactUploadTransportAllowed("127.0.0.1", "203.0.113.20", "http", + java.util.Set.of("127.0.0.1"), true)); + assertFalse(ControlHttpServer.artifactUploadTransportAllowed("127.0.0.1", null, "http", + java.util.Set.of("127.0.0.1"), true)); + assertFalse(ControlHttpServer.artifactUploadTransportAllowed("203.0.113.20", null, "https", + java.util.Set.of("127.0.0.1"), true), "untrusted peers cannot spoof forwarded HTTPS"); + } + @Test void queuedPasswordVerificationDoesNotBlockHealthOrAdminRequests() throws Exception { credentials.setWebPassword("a-secure-web-password".toCharArray()); java.util.List>> attempts = From dd736bdba3137602a99cd301c49c9b9e53540ea4 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:34:13 -0600 Subject: [PATCH 2/3] Fix Jenkins artifact and private upload validation --- .../artifact/JenkinsVotingPluginSource.java | 53 +++++++++++++--- .../control/http/ControlHttpServer.java | 33 +++++++--- src/main/resources/web/app.js | 24 ++++--- .../JenkinsVotingPluginSourceTest.java | 47 +++++++++++--- .../control/http/ControlHttpServerTest.java | 63 ++++++++++++++++++- 5 files changed, 189 insertions(+), 31 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/artifact/JenkinsVotingPluginSource.java b/src/main/java/com/bencodez/votingplugin/control/artifact/JenkinsVotingPluginSource.java index 9563b44..3c2aaa1 100644 --- a/src/main/java/com/bencodez/votingplugin/control/artifact/JenkinsVotingPluginSource.java +++ b/src/main/java/com/bencodez/votingplugin/control/artifact/JenkinsVotingPluginSource.java @@ -5,6 +5,7 @@ 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; @@ -21,8 +22,7 @@ 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]+\\.jar"); - private static final Pattern ARTIFACT_PATH = Pattern.compile("VotingPlugin/target/VotingPlugin-[0-9]+\\.jar"); + private static final Pattern ARTIFACT_NAME = Pattern.compile("VotingPlugin-[0-9][A-Za-z0-9._-]*\\.jar"); private final URI job; private final HttpClient client; @@ -99,8 +99,7 @@ private Build readBuild(URI uri, Integer expectedNumber) throws IOException { for (JsonNode candidate : artifactList) { String candidateName = candidate.path("fileName").asText(""); String candidatePath = candidate.path("relativePath").asText(""); - if (candidateName.equals("VotingPlugin-" + number + ".jar") - && candidatePath.equals("VotingPlugin/target/" + candidateName)) { + if (isExpectedArtifact(candidateName, candidatePath)) { if (artifact != null) throw unavailable(); artifact = candidate; } @@ -108,15 +107,18 @@ private Build readBuild(URI uri, Integer expectedNumber) throws IOException { if (artifact == null) throw unavailable(); String fileName = artifact.path("fileName").asText(""); String relativePath = artifact.path("relativePath").asText(""); - if (!ARTIFACT_NAME.matcher(fileName).matches() || !ARTIFACT_PATH.matcher(relativePath).matches() - || !fileName.equals("VotingPlugin-" + number + ".jar") - || !relativePath.equals("VotingPlugin/target/" + fileName)) throw unavailable(); + 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) @@ -170,7 +172,42 @@ private static void closeQuietly(InputStream input) { public record Build(int buildNumber, long timestamp, String fileName, String relativePath) { } - public record Download(Build build, InputStream body) implements AutoCloseable { + 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; + } + } + } } diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index aab4374..6c624ee 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -560,9 +560,14 @@ private void route(HttpExchange exchange) throws IOException { requireRequest(request); try (JenkinsVotingPluginSource.Download download = jenkinsSource.open(request.buildNumber())) { ArtifactStore.Artifact artifact; - synchronized (deploymentArtifactLifecycle) { - artifact = withDownloadDeadline(download, () -> artifactStore.upload(download.body(), - download.build().fileName(), null, deploymentOperations.referencedArtifactIds())); + try { + synchronized (deploymentArtifactLifecycle) { + artifact = withDownloadDeadline(download, () -> artifactStore.upload(download.body(), + download.build().fileName(), null, deploymentOperations.referencedArtifactIds())); + } + } catch (ArtifactException failure) { + if (download.sourceReadFailed()) throw new JenkinsSourceException(failure); + throw failure; } send(exchange, 201, Map.of("artifactId", artifact.artifactId(), "sha256", artifact.artifactId(), "size", artifact.size(), @@ -1198,25 +1203,37 @@ private void requireSafeArtifactUploadTransport(HttpExchange exchange) { InetSocketAddress remote = exchange.getRemoteAddress(); if (remote == null) throw localUploadRequired(); String peer = remote.getAddress() == null ? remote.getHostString() : remote.getAddress().getHostAddress(); - if (remote.getAddress() != null && !trustedProxyAddresses.contains(peer) - && isLocalNetworkAddress(remote.getAddress())) return; if (!artifactUploadTransportAllowed(peer, singleHeader(exchange, "X-Forwarded-For"), - singleHeader(exchange, "X-Forwarded-Proto"), trustedProxyAddresses, secureCookies)) { + singleHeader(exchange, "X-Forwarded-Proto"), trustedProxyAddresses, secureCookies, + hasForwardedHeaders(exchange))) { throw localUploadRequired(); } } static boolean artifactUploadTransportAllowed(String peer, String forwardedFor, String forwardedProto, Set trustedProxies, boolean trustForwardedHttps) { + return artifactUploadTransportAllowed(peer, forwardedFor, forwardedProto, trustedProxies, + trustForwardedHttps, forwardedFor != null || forwardedProto != null); + } + + private static boolean artifactUploadTransportAllowed(String peer, String forwardedFor, String forwardedProto, + Set trustedProxies, boolean trustForwardedHttps, + boolean forwardedHeadersPresent) { boolean trustedProxy = trustedProxies.contains(peer); if (trustedProxy) { return trustForwardedHttps && "https".equalsIgnoreCase(forwardedProto); } - String client = forwardedPasswordClient(peer, forwardedFor, trustedProxies); - String canonical = canonicalIpLiteral(client); + if (forwardedHeadersPresent) return false; + String canonical = canonicalIpLiteral(peer); return canonical != null && isLocalNetworkAddress(canonical); } + private static boolean hasForwardedHeaders(HttpExchange exchange) { + return exchange.getRequestHeaders().containsKey("Forwarded") + || exchange.getRequestHeaders().containsKey("X-Forwarded-For") + || exchange.getRequestHeaders().containsKey("X-Forwarded-Proto"); + } + static boolean isLocalNetworkAddress(String literal) { String canonical = canonicalIpLiteral(literal); if (canonical == null) return false; diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 949c186..e1a2f7e 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -5880,9 +5880,9 @@ document.querySelector('#choose-global').addEventListener('click', enterGlobalWo document.querySelector('#scope-overview').addEventListener('click', openScopeOverview); deploymentJar.addEventListener('change', renderDeploymentEligibility); -async function stageVerifiedArtifact(artifact, confirmation) { +async function stageVerifiedArtifact(artifact, confirmation, reservedDeploymentRun = null) { const eligible = deploymentTargets(); - if (!authenticated || logoutInFlight || !eligible.length || deploymentInFlight) return; + if (!authenticated || logoutInFlight || !eligible.length || (deploymentInFlight && reservedDeploymentRun == null)) return; const batches = []; for (let offset = 0; offset < eligible.length; offset += MAX_OPERATION_TARGETS) { batches.push(eligible.slice(offset, offset + MAX_OPERATION_TARGETS)); @@ -5898,9 +5898,11 @@ async function stageVerifiedArtifact(artifact, confirmation) { + (batches.length > 1 ? ` Control will use ${batches.length} bounded operations.` : '') + (ineligible.length ? ` Older/incompatible nodes excluded: ${ineligible.join(', ')}.` : ''); if (!window.confirm(prompt)) return; - const deploymentRun = ++deploymentRunGeneration; - deploymentInFlight = true; - renderDeploymentEligibility(); + const deploymentRun = reservedDeploymentRun ?? ++deploymentRunGeneration; + if (reservedDeploymentRun == null) { + deploymentInFlight = true; + renderDeploymentEligibility(); + } const generation = authenticationGeneration; const submittedOperations = []; const completedOperations = []; @@ -5970,7 +5972,7 @@ async function stageVerifiedArtifact(artifact, confirmation) { : `${error.message}${unavailableSummary}`); } } finally { - if (deploymentRun === deploymentRunGeneration) { + if (reservedDeploymentRun == null && deploymentRun === deploymentRunGeneration) { deploymentInFlight = false; renderDeploymentEligibility(); } @@ -6001,6 +6003,9 @@ deployPlugin.addEventListener('click', async () => { deployJenkins.addEventListener('click', async () => { if (!authenticated || logoutInFlight || deploymentInFlight) return; + const deploymentRun = ++deploymentRunGeneration; + deploymentInFlight = true; + renderDeploymentEligibility(); try { text(deploymentStatus, 'Checking the latest successful bencodez.com Jenkins build…'); const build = await authorized('/api/v1/artifacts/votingplugin/jenkins'); @@ -6014,9 +6019,14 @@ deployJenkins.addEventListener('click', async () => { throw new Error('The selected Jenkins build changed before staging.'); } return downloaded; - }, `Download successful Jenkins build ${build.buildNumber} (${build.fileName}) from bencodez.com.`); + }, `Download successful Jenkins build ${build.buildNumber} (${build.fileName}) from bencodez.com.`, deploymentRun); } catch (error) { if (authenticated) text(deploymentStatus, error.message); + } finally { + if (deploymentRun === deploymentRunGeneration) { + deploymentInFlight = false; + renderDeploymentEligibility(); + } } }); tabButtons.forEach(button => button.addEventListener('click', () => { diff --git a/src/test/java/com/bencodez/votingplugin/control/artifact/JenkinsVotingPluginSourceTest.java b/src/test/java/com/bencodez/votingplugin/control/artifact/JenkinsVotingPluginSourceTest.java index 9d7b4c2..fc4678c 100644 --- a/src/test/java/com/bencodez/votingplugin/control/artifact/JenkinsVotingPluginSourceTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/artifact/JenkinsVotingPluginSourceTest.java @@ -34,16 +34,16 @@ class JenkinsVotingPluginSourceTest { @Test void readsFixedSuccessfulBuildAndDownloadsExactArtifact() throws Exception { byte[] jar = votingPluginJar(); server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - respond("/job/VotingPlugin/lastSuccessfulBuild/api/json", metadata(2393)); - respond("/job/VotingPlugin/2393/api/json", metadata(2393)); - respond("/job/VotingPlugin/2393/artifact/VotingPlugin/target/VotingPlugin-2393.jar", jar, + respond("/job/VotingPlugin/lastSuccessfulBuild/api/json", metadata(2393, "VotingPlugin-7.1.2-SNAPSHOT.jar")); + respond("/job/VotingPlugin/2393/api/json", metadata(2393, "VotingPlugin-7.1.2-SNAPSHOT.jar")); + respond("/job/VotingPlugin/2393/artifact/VotingPlugin/target/VotingPlugin-7.1.2-SNAPSHOT.jar", jar, "application/java-archive"); server.start(); JenkinsVotingPluginSource source = source(); assertEquals(2393, source.latestSuccessful().buildNumber()); try (JenkinsVotingPluginSource.Download download = source.open(2393)) { - assertEquals("VotingPlugin-2393.jar", download.build().fileName()); + assertEquals("VotingPlugin-7.1.2-SNAPSHOT.jar", download.build().fileName()); ArtifactStore.Artifact artifact = new ArtifactStore(directory).upload(download.body(), download.build().fileName(), null); assertEquals(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(jar)), @@ -69,6 +69,15 @@ class JenkinsVotingPluginSourceTest { assertThrows(java.io.IOException.class, () -> source.open(2394)); } + @Test void rejectsUnsafeOrNonTargetArtifactPaths() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + respond("/job/VotingPlugin/lastSuccessfulBuild/api/json", + metadata(2393, "VotingPlugin-7.1.2.jar", "VotingPlugin/target/../VotingPlugin-7.1.2.jar")); + server.start(); + + assertThrows(java.io.IOException.class, () -> source().latestSuccessful()); + } + @Test void boundsStalledMetadataBodyReads() throws Exception { CountDownLatch headersSent = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); @@ -97,6 +106,22 @@ class JenkinsVotingPluginSourceTest { } } + @Test void recordsInterruptedArtifactSourceReads() throws Exception { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + respond("/job/VotingPlugin/2393/api/json", metadata(2393)); + server.createContext("/job/VotingPlugin/2393/artifact/VotingPlugin/target/VotingPlugin-2393.jar", exchange -> { + exchange.sendResponseHeaders(200, 2); + exchange.getResponseBody().write(new byte[] {1}); + exchange.close(); + }); + server.start(); + + try (JenkinsVotingPluginSource.Download download = source().open(2393)) { + assertThrows(java.io.IOException.class, () -> download.body().readAllBytes()); + assertTrue(download.sourceReadFailed()); + } + } + private JenkinsVotingPluginSource source() { return source(Duration.ofSeconds(30)); } @@ -122,11 +147,19 @@ private void respond(String path, byte[] body, String contentType) { } private static String metadata(int build) { + return metadata(build, "VotingPlugin-" + build + ".jar"); + } + + private static String metadata(int build, String fileName) { + return metadata(build, fileName, "VotingPlugin/target/" + fileName); + } + + private static String metadata(int build, String fileName, String relativePath) { return """ {"number":%d,"result":"SUCCESS","building":false,"timestamp":1790648737343, - "artifacts":[{"fileName":"VotingPlugin-%d.jar", - "relativePath":"VotingPlugin/target/VotingPlugin-%d.jar"}]} - """.formatted(build, build, build); + "artifacts":[{"fileName":"%s", + "relativePath":"%s"}]} + """.formatted(build, fileName, relativePath); } private static byte[] votingPluginJar() throws Exception { diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 990239d..12696ef 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -144,6 +144,51 @@ function capture(nodes) { assertTrue(states.path("empty").path("disabled").asBoolean()); } + @Test void jenkinsDeploymentSelectionReservesItsBusyStateDuringMetadataLookup() throws Exception { + org.junit.jupiter.api.Assumptions.assumeTrue(nodeAvailable(), + "Node.js is required to execute the WebUI behavior regression"); + String app; + try (InputStream input = ControlHttpServerTest.class.getResourceAsStream("/web/app.js")) { + assertNotNull(input); + app = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + int start = app.indexOf("deployJenkins.addEventListener('click', async () => {"); + int end = app.indexOf("\ntabButtons.forEach", start); + assertTrue(start >= 0 && end > start, "Jenkins deployment handler must remain discoverable"); + String handler = app.substring(start, end); + + String harness = """ + let handler; + const deployJenkins = {addEventListener: (_, listener) => { handler = listener; }}; + let authenticated = true; + let logoutInFlight = false; + let deploymentInFlight = false; + let deploymentRunGeneration = 0; + const deploymentStatus = {}; + function text() {} + function renderDeploymentEligibility() {} + async function stageVerifiedArtifact() {} + let rejectMetadata; + function authorized() { return new Promise((_, reject) => { rejectMetadata = reject; }); } + """ + handler + """ + const pending = handler(); + if (!deploymentInFlight || deploymentRunGeneration !== 1) throw new Error('Jenkins lookup was not reserved'); + rejectMetadata(new Error('source unavailable')); + await pending; + if (deploymentInFlight) throw new Error('Jenkins lookup did not release its reservation'); + """; + + Process process = new ProcessBuilder("node", "--input-type=module", "-e", harness).redirectErrorStream(true).start(); + String output; + try { + if (!process.waitFor(5, TimeUnit.SECONDS)) fail("WebUI behavior test timed out"); + output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertEquals(0, process.exitValue(), output); + } finally { + terminateProcess(process); + } + } + private static boolean nodeAvailable() { Process process = null; try { @@ -252,7 +297,8 @@ private static void terminateProcess(Process process) { assertTrue(script.body().contains("authenticationGeneration")); assertTrue(script.body().contains("if (loginInFlight) return")); assertTrue(script.body().contains("logoutInFlight && path !== '/api/v1/auth/logout'")); - assertTrue(script.body().contains("!authenticated || logoutInFlight || !eligible.length || deploymentInFlight")); + assertTrue(script.body().contains("!authenticated || logoutInFlight || !eligible.length" + + " || (deploymentInFlight && reservedDeploymentRun == null)")); assertTrue(script.body().contains("const deploymentRun = ++deploymentRunGeneration;")); assertTrue(script.body().contains("if (deploymentRun === deploymentRunGeneration) {\n" + " deploymentInFlight = false;"), @@ -1368,6 +1414,17 @@ private static void terminateProcess(Process process) { .at("/items/0/deploymentId").asText()); } + @Test void untrustedForwardedHeadersRejectPrivatePeerArtifactUploads() throws Exception { + byte[] jar = votingPluginJar(); + String sha256 = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(jar)); + Map headers = Map.of("Authorization", "Bearer " + adminToken, + "Content-Type", "application/java-archive", "X-Filename", "VotingPlugin.jar", + "X-Artifact-SHA256", sha256, "X-Forwarded-For", "203.0.113.20"); + + assertError(sendBytes("POST", "/api/v1/artifacts/votingplugin", jar, headers), 403, + "LOCAL_NETWORK_REQUIRED"); + } + @Test void missingInvalidWrongNodeRevokedAndRotatedCredentialsFailWithoutDisclosure() throws Exception { assertAuthFailure(send("POST", "/api/v1/nodes/register", registration(), null)); assertAuthFailure(send("POST", "/api/v1/nodes/register", registration(), "wrong")); @@ -1565,6 +1622,10 @@ private static void terminateProcess(Process process) { java.util.Set.of("127.0.0.1"), true)); assertFalse(ControlHttpServer.artifactUploadTransportAllowed("203.0.113.20", null, "https", java.util.Set.of("127.0.0.1"), true), "untrusted peers cannot spoof forwarded HTTPS"); + assertFalse(ControlHttpServer.artifactUploadTransportAllowed("192.168.0.20", "203.0.113.20", null, + java.util.Set.of(), false), "private peers with forwarded client identity are untrusted proxies"); + assertFalse(ControlHttpServer.artifactUploadTransportAllowed("192.168.0.20", null, "https", + java.util.Set.of(), false), "private peers with forwarded protocol are untrusted proxies"); } @Test void queuedPasswordVerificationDoesNotBlockHealthOrAdminRequests() throws Exception { From a402bb21993245d0e93befabb0593da8ea3d32ca Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:12:12 -0600 Subject: [PATCH 3/3] Bound artifact lifecycle admission and classify scoped socket peers --- README.md | 2 + docs/control-management.md | 5 + .../control/http/ControlHttpServer.java | 83 +++++---- .../control/http/ControlHttpServerTest.java | 163 ++++++++++++++++++ 4 files changed, 221 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 2861a9a..4348d94 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,8 @@ JAR uploads through a reverse proxy require HTTPS: set `CONTROL_SECURE_COOKIE=tr `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 diff --git a/docs/control-management.md b/docs/control-management.md index 93e64b6..1957e59 100644 --- a/docs/control-management.md +++ b/docs/control-management.md @@ -516,6 +516,11 @@ the peer in `CONTROL_TRUSTED_PROXY_ADDRESSES`, `CONTROL_SECURE_COOKIE=true`, and `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 | diff --git a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java index 6c624ee..0c97217 100644 --- a/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java +++ b/src/main/java/com/bencodez/votingplugin/control/http/ControlHttpServer.java @@ -68,6 +68,7 @@ import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; /** Bounded HTTP adapter. Node writes are enrolled; node listings require the local admin credential. */ public final class ControlHttpServer implements AutoCloseable { @@ -120,7 +121,7 @@ public final class ControlHttpServer implements AutoCloseable { private final ArtifactStore artifactStore; private final JenkinsVotingPluginSource jenkinsSource = new JenkinsVotingPluginSource(); private final DeploymentOperations deploymentOperations; - private final Object deploymentArtifactLifecycle = new Object(); + private final ReentrantLock deploymentArtifactLifecycle = new ReentrantLock(); private final ThreadPoolExecutor executor; private final ThreadPoolExecutor passwordExecutor; private final ScheduledExecutorService artifactTransferTimeouts; @@ -336,7 +337,8 @@ private void handle(HttpExchange exchange) throws IOException { "PREVIEW_INCOMPLETE", "APPROVAL_REQUIRED", "NODE_UNAVAILABLE", "OPERATION_LIMIT", "REGISTRY_LIMIT", "REGISTRY_CHANGED", "TASK_NOT_CLAIMED", "TASK_LEASE_EXPIRED", "SETUP_COMPLETE", "OPERATION_INCOMPLETE", "PREVIEW_REQUIRED", - "RETRY_REQUIRES_INPUT", "TARGET_CHANGED", "OPERATION_CONFLICT" -> 409; + "RETRY_REQUIRES_INPUT", "TARGET_CHANGED", "OPERATION_CONFLICT", + "ARTIFACT_LIFECYCLE_BUSY" -> 409; case "UNSUPPORTED_MEDIA_TYPE" -> 415; case "LOCAL_NETWORK_REQUIRED" -> 403; default -> 400; @@ -534,12 +536,10 @@ private void route(HttpExchange exchange) throws IOException { requireBodyWithin(exchange, ArtifactStore.MAX_UPLOAD_BYTES); String filename = requiredHeader(exchange, "X-Filename"); String claimedSha256 = optionalSingleHeader(exchange, "X-Artifact-SHA256"); - ArtifactStore.Artifact artifact; - synchronized (deploymentArtifactLifecycle) { - artifact = withArtifactTransferDeadline(exchange, + ArtifactStore.Artifact artifact = withDeploymentArtifactLifecycle(() -> + withArtifactTransferDeadline(exchange, () -> artifactStore.upload(exchange.getRequestBody(), filename, claimedSha256, - deploymentOperations.referencedArtifactIds())); - } + deploymentOperations.referencedArtifactIds()))); send(exchange, 201, Map.of("artifactId", artifact.artifactId(), "sha256", artifact.artifactId(), "size", artifact.size(), "fileName", artifact.displayFilename())); return; @@ -558,26 +558,27 @@ private void route(HttpExchange exchange) throws IOException { authenticateAdmin(exchange, true); JenkinsArtifactRequest request = read(exchange, JenkinsArtifactRequest.class); requireRequest(request); - try (JenkinsVotingPluginSource.Download download = jenkinsSource.open(request.buildNumber())) { - ArtifactStore.Artifact artifact; - try { - synchronized (deploymentArtifactLifecycle) { + Map artifactResponse = withDeploymentArtifactLifecycle(() -> { + try (JenkinsVotingPluginSource.Download download = jenkinsSource.open(request.buildNumber())) { + ArtifactStore.Artifact artifact; + try { artifact = withDownloadDeadline(download, () -> artifactStore.upload(download.body(), download.build().fileName(), null, deploymentOperations.referencedArtifactIds())); + } catch (ArtifactException failure) { + if (download.sourceReadFailed()) throw new JenkinsSourceException(failure); + throw failure; } + return Map.of("artifactId", artifact.artifactId(), + "sha256", artifact.artifactId(), "size", artifact.size(), + "fileName", artifact.displayFilename(), "buildNumber", download.build().buildNumber(), + "timestamp", download.build().timestamp()); } catch (ArtifactException failure) { - if (download.sourceReadFailed()) throw new JenkinsSourceException(failure); throw failure; + } catch (IOException failure) { + throw new JenkinsSourceException(failure); } - send(exchange, 201, Map.of("artifactId", artifact.artifactId(), - "sha256", artifact.artifactId(), "size", artifact.size(), - "fileName", artifact.displayFilename(), "buildNumber", download.build().buildNumber(), - "timestamp", download.build().timestamp())); - } catch (ArtifactException failure) { - throw failure; - } catch (IOException failure) { - throw new JenkinsSourceException(failure); - } + }); + send(exchange, 201, artifactResponse); return; } exchange.getResponseHeaders().set("Allow", "GET, POST"); @@ -599,15 +600,14 @@ private void route(HttpExchange exchange) throws IOException { authenticateAdmin(exchange, true); DeploymentRequest request = read(exchange, DeploymentRequest.class); requireRequest(request); - DeploymentResult deployment; - synchronized (deploymentArtifactLifecycle) { + DeploymentResult deployment = withDeploymentArtifactLifecycle(() -> { ArtifactStore.Artifact artifact = artifactStore.describe(request.artifactId()); if (!artifact.artifactId().equals(request.sha256()) || artifact.size() != request.size()) { throw new ValidationException("ARTIFACT_MISMATCH", "Deployment metadata does not match the verified artifact", List.of()); } - deployment = deploymentOperations.create(request); - } + return deploymentOperations.create(request); + }); send(exchange, 202, deployment); return; } @@ -1112,6 +1112,18 @@ static void sendArtifact(HttpExchange exchange, ArtifactStore.Artifact artifact, } } + private T withDeploymentArtifactLifecycle(IoSupplier operation) throws IOException { + if (!deploymentArtifactLifecycle.tryLock()) { + throw new ValidationException("ARTIFACT_LIFECYCLE_BUSY", + "Artifact ingestion or deployment creation is in progress; retry after it completes", List.of()); + } + try { + return operation.get(); + } finally { + deploymentArtifactLifecycle.unlock(); + } + } + private T withArtifactTransferDeadline(HttpExchange exchange, IoSupplier transfer) throws IOException { ScheduledFuture timeout = artifactTransferTimeouts.schedule(exchange::close, ARTIFACT_TRANSFER_TIMEOUT_SECONDS, TimeUnit.SECONDS); @@ -1202,9 +1214,8 @@ private void requireSafeArtifactUploadTransport(HttpExchange exchange) { if (exchange instanceof HttpsExchange) return; InetSocketAddress remote = exchange.getRemoteAddress(); if (remote == null) throw localUploadRequired(); - String peer = remote.getAddress() == null ? remote.getHostString() : remote.getAddress().getHostAddress(); - if (!artifactUploadTransportAllowed(peer, singleHeader(exchange, "X-Forwarded-For"), - singleHeader(exchange, "X-Forwarded-Proto"), trustedProxyAddresses, secureCookies, + if (!artifactUploadTransportAllowed(remote, singleHeader(exchange, "X-Forwarded-Proto"), + trustedProxyAddresses, secureCookies, hasForwardedHeaders(exchange))) { throw localUploadRequired(); } @@ -1212,11 +1223,20 @@ private void requireSafeArtifactUploadTransport(HttpExchange exchange) { static boolean artifactUploadTransportAllowed(String peer, String forwardedFor, String forwardedProto, Set trustedProxies, boolean trustForwardedHttps) { - return artifactUploadTransportAllowed(peer, forwardedFor, forwardedProto, trustedProxies, + return artifactUploadTransportAllowed(peer, null, forwardedProto, trustedProxies, trustForwardedHttps, forwardedFor != null || forwardedProto != null); } - private static boolean artifactUploadTransportAllowed(String peer, String forwardedFor, String forwardedProto, + private static boolean artifactUploadTransportAllowed(InetSocketAddress remote, String forwardedProto, + Set trustedProxies, + boolean trustForwardedHttps, boolean forwardedHeadersPresent) { + InetAddress resolved = remote.getAddress(); + String peer = resolved == null ? remote.getHostString() : resolved.getHostAddress(); + return artifactUploadTransportAllowed(peer, resolved, forwardedProto, trustedProxies, trustForwardedHttps, + forwardedHeadersPresent); + } + + private static boolean artifactUploadTransportAllowed(String peer, InetAddress resolved, String forwardedProto, Set trustedProxies, boolean trustForwardedHttps, boolean forwardedHeadersPresent) { boolean trustedProxy = trustedProxies.contains(peer); @@ -1224,8 +1244,7 @@ private static boolean artifactUploadTransportAllowed(String peer, String forwar return trustForwardedHttps && "https".equalsIgnoreCase(forwardedProto); } if (forwardedHeadersPresent) return false; - String canonical = canonicalIpLiteral(peer); - return canonical != null && isLocalNetworkAddress(canonical); + return resolved != null ? isLocalNetworkAddress(resolved) : isLocalNetworkAddress(peer); } private static boolean hasForwardedHeaders(HttpExchange exchange) { diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 12696ef..7600f2c 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -1425,6 +1425,120 @@ private static void terminateProcess(Process process) { "LOCAL_NETWORK_REQUIRED"); } + @Test void activeIngestionRejectsConcurrentArtifactAndDeploymentRequestsWithoutBlockingWorkers() throws Exception { + String capableRegistration = registration().replace("\"presence.snapshot\"]", + "\"presence.snapshot\",\"plugin.deploy.v1\"]"); + assertEquals(201, send("POST", "/api/v1/nodes/register", capableRegistration, nodeToken).statusCode()); + byte[] jar = votingPluginJar(); + String sha256 = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(jar)); + Map uploadHeaders = Map.of("Authorization", "Bearer " + adminToken, + "Content-Type", "application/java-archive", "X-Filename", "VotingPlugin.jar"); + assertEquals(201, sendBytes("POST", "/api/v1/artifacts/votingplugin", jar, uploadHeaders).statusCode()); + String deployment = "{\"artifactId\":\"" + sha256 + "\",\"sha256\":\"" + sha256 + + "\",\"size\":" + jar.length + ",\"nodeIds\":[\"proxy-a\"]}"; + java.util.concurrent.CountDownLatch reading = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch release = new java.util.concurrent.CountDownLatch(1); + InputStream heldBody = new java.io.FilterInputStream(new ByteArrayInputStream(jar)) { + @Override public int read(byte[] bytes, int offset, int length) throws IOException { + reading.countDown(); + try { + if (!release.await(10, TimeUnit.SECONDS)) throw new IOException("Test upload was not released"); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IOException(failure); + } + return super.read(bytes, offset, length); + } + }; + BoundaryExchange owner = new BoundaryExchange("/api/v1/artifacts/votingplugin", + new InetSocketAddress("127.0.0.1", 50000), heldBody, uploadHeaders); + java.util.concurrent.CompletableFuture upload = java.util.concurrent.CompletableFuture.runAsync(() -> { + try { + handleAtBoundary(owner); + } catch (Exception failure) { + throw new java.util.concurrent.CompletionException(failure); + } + }); + try { + assertTrue(reading.await(3, TimeUnit.SECONDS), "Owner must start reading under lifecycle ownership"); + InputStream unreadBody = new InputStream() { + @Override public int read() { throw new AssertionError("Busy upload must not acquire its body"); } + }; + BoundaryExchange busyUpload = new BoundaryExchange("/api/v1/artifacts/votingplugin", + new InetSocketAddress("127.0.0.1", 50001), unreadBody, uploadHeaders); + handleAtBoundary(busyUpload); + assertEquals(409, busyUpload.status); + assertFalse(busyUpload.bodyAcquired); + assertEquals("ARTIFACT_LIFECYCLE_BUSY", json.readTree(busyUpload.response.toByteArray()) + .at("/error/code").asText()); + + java.util.List>> competing = + new java.util.ArrayList<>(); + for (int index = 0; index < 24; index++) { + int kind = index % 3; + HttpRequest.Builder request = HttpRequest.newBuilder(base.resolve(kind == 0 + ? "/api/v1/artifacts/votingplugin" : kind == 1 + ? "/api/v1/artifacts/votingplugin/jenkins" : "/api/v1/deployments")) + .timeout(Duration.ofSeconds(3)); + if (kind == 0) { + uploadHeaders.forEach(request::header); + request.POST(HttpRequest.BodyPublishers.ofByteArray(jar)); + } else { + request.header("Authorization", "Bearer " + adminToken).header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(kind == 1 ? "{\"buildNumber\":1}" : deployment)); + } + competing.add(client.sendAsync(request.build(), HttpResponse.BodyHandlers.ofString())); + } + assertEquals(200, get("/api/v1/health", null).statusCode()); + assertEquals(200, get("/api/v1/nodes", adminToken).statusCode()); + for (var response : competing) { + assertError(response.get(3, TimeUnit.SECONDS), 409, "ARTIFACT_LIFECYCLE_BUSY"); + } + } finally { + release.countDown(); + upload.get(3, TimeUnit.SECONDS); + } + assertEquals(201, owner.status, owner.response.toString(StandardCharsets.UTF_8)); + assertEquals(202, send("POST", "/api/v1/deployments", deployment, adminToken).statusCode()); + assertEquals(201, sendBytes("POST", "/api/v1/artifacts/votingplugin", jar, uploadHeaders).statusCode()); + } + + @Test void rejectedArtifactReleasesLifecycleAdmission() throws Exception { + Map headers = Map.of("Authorization", "Bearer " + adminToken, + "Content-Type", "application/java-archive", "X-Filename", "VotingPlugin.jar"); + assertError(sendBytes("POST", "/api/v1/artifacts/votingplugin", new byte[] {1, 2, 3}, headers), + 400, "ARTIFACT_REJECTED"); + assertEquals(201, sendBytes("POST", "/api/v1/artifacts/votingplugin", votingPluginJar(), headers).statusCode()); + } + + @Test void scopedIpv6SocketPeerIsClassifiedAtTheUploadBoundary() throws Exception { + java.net.Inet6Address peer = java.net.Inet6Address.getByAddress(null, + HexFormat.of().parseHex("fe800000000000000000000000000001"), 2); + assertTrue(peer.getHostAddress().endsWith("%2")); + // External literals remain scope-free and cannot trigger a hostname lookup. + assertFalse(ControlHttpServer.isLocalNetworkAddress(peer.getHostAddress())); + Map headers = Map.of("Authorization", "Bearer " + adminToken, + "Content-Type", "application/java-archive", "X-Filename", "VotingPlugin.jar"); + BoundaryExchange direct = new BoundaryExchange("/api/v1/artifacts/votingplugin", + new InetSocketAddress(peer, 50000), new ByteArrayInputStream(votingPluginJar()), headers); + handleAtBoundary(direct); + assertEquals(201, direct.status, direct.response.toString(StandardCharsets.UTF_8)); + + BoundaryExchange forwarded = new BoundaryExchange("/api/v1/artifacts/votingplugin", + new InetSocketAddress(peer, 50000), new ByteArrayInputStream(votingPluginJar()), headers); + forwarded.requestHeaders.set("Forwarded", "for=192.168.0.20;proto=https"); + handleAtBoundary(forwarded); + assertEquals(403, forwarded.status); + assertFalse(forwarded.bodyAcquired); + + BoundaryExchange unresolved = new BoundaryExchange("/api/v1/artifacts/votingplugin", + InetSocketAddress.createUnresolved("localhost", 50000), + new ByteArrayInputStream(votingPluginJar()), headers); + handleAtBoundary(unresolved); + assertEquals(403, unresolved.status); + assertFalse(unresolved.bodyAcquired); + } + @Test void missingInvalidWrongNodeRevokedAndRotatedCredentialsFailWithoutDisclosure() throws Exception { assertAuthFailure(send("POST", "/api/v1/nodes/register", registration(), null)); assertAuthFailure(send("POST", "/api/v1/nodes/register", registration(), "wrong")); @@ -1822,6 +1936,55 @@ private void assertError(HttpResponse response, int status, String code) assertEquals(code, json.readTree(response.body()).at("/error/code").asText(), response.body()); } + private void handleAtBoundary(HttpExchange exchange) throws Exception { + var handle = ControlHttpServer.class.getDeclaredMethod("handle", HttpExchange.class); + handle.setAccessible(true); + try { + handle.invoke(server, exchange); + } catch (java.lang.reflect.InvocationTargetException failure) { + if (failure.getCause() instanceof Exception cause) throw cause; + if (failure.getCause() instanceof Error cause) throw cause; + throw failure; + } + } + + private static final class BoundaryExchange extends HttpExchange { + private final Headers requestHeaders = new Headers(); + private final Headers responseHeaders = new Headers(); + private final ByteArrayOutputStream response = new ByteArrayOutputStream(); + private final String path; + private final InetSocketAddress remote; + private final InputStream body; + private int status = -1; + private boolean bodyAcquired; + + private BoundaryExchange(String path, InetSocketAddress remote, InputStream body, + Map headers) { + this.path = path; + this.remote = remote; + this.body = body; + headers.forEach(requestHeaders::set); + } + + @Override public Headers getRequestHeaders() { return requestHeaders; } + @Override public Headers getResponseHeaders() { return responseHeaders; } + @Override public URI getRequestURI() { return URI.create(path); } + @Override public String getRequestMethod() { return "POST"; } + @Override public HttpContext getHttpContext() { return null; } + @Override public void close() {} + @Override public InputStream getRequestBody() { bodyAcquired = true; return body; } + @Override public OutputStream getResponseBody() { return response; } + @Override public void sendResponseHeaders(int code, long length) { status = code; } + @Override public InetSocketAddress getRemoteAddress() { return remote; } + @Override public int getResponseCode() { return status; } + @Override public InetSocketAddress getLocalAddress() { return new InetSocketAddress(0); } + @Override public String getProtocol() { return "HTTP/1.1"; } + @Override public Object getAttribute(String name) { return null; } + @Override public void setAttribute(String name, Object value) {} + @Override public void setStreams(InputStream input, OutputStream output) {} + @Override public HttpPrincipal getPrincipal() { return null; } + } + private static final class FailingHeadersExchange extends HttpExchange { private final Headers responseHeaders = new Headers(); @Override public Headers getRequestHeaders() { return new Headers(); }