From d37dd98842f82edc00e66dd8ec705e3e070a0981 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:16:04 -0600 Subject: [PATCH 1/5] Allow verified Control staging over HTTP --- .../control/BackendControlConnector.java | 9 ++-- .../control/PluginDeploymentService.java | 42 +++++++++---------- .../proxy/control/ControlConnector.java | 9 ++-- VotingPlugin/src/main/resources/Config.yml | 3 +- .../src/main/resources/bungeeconfig.yml | 2 + .../control/PluginDeploymentServiceTest.java | 29 ++++++++----- docs/control-agent-contract.md | 7 ++-- docs/control-connector.md | 8 ++-- 8 files changed, 62 insertions(+), 47 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index 82fbffe0c..7348e2704 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -142,8 +142,7 @@ private BackendControlConnector(VotingPluginMain plugin, Path dataDirectory, Set directLocalDeploymentEndpoint = HostedControlManager.isDirectLocalEndpoint( settings.endpoint().toString(), hostedConfiguration); PluginDeploymentService prepared = null; - boolean deploymentEndpointAllowed = PluginDeploymentService.credentialEndpointAllowed( - settings.endpoint(), directLocalDeploymentEndpoint); + boolean deploymentEndpointAllowed = PluginDeploymentService.deploymentEndpointAllowed(settings.endpoint()); if (!recovering && deploymentEndpointAllowed) { try { prepared = PluginDeploymentService.backend(plugin.getServer().getUpdateFolderFile().toPath(), @@ -152,7 +151,11 @@ private BackendControlConnector(VotingPluginMain plugin, Path dataDirectory, Set plugin.getLogger().warning("[Control] Plugin deployment staging is unavailable; capability not advertised"); } } else if (!recovering && !deploymentEndpointAllowed) { - plugin.getLogger().warning("[Control] Plugin deployment staging requires HTTPS unless Control is hosted directly on this node"); + plugin.getLogger().warning("[Control] Plugin deployment staging requires an HTTP or HTTPS Control endpoint"); + } + if (prepared != null && PluginDeploymentService.usesUnencryptedHttp(settings.endpoint())) { + plugin.getLogger().warning("[Control] Verified plugin staging is enabled over unencrypted HTTP. " + + "HTTPS is strongly recommended because node credentials and plugin artifacts cross this connection"); } deployments = prepared; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java index 2b5256738..161ac120b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java @@ -122,9 +122,9 @@ public Result deploy(Task task, URI endpoint, boolean directLocalHosted, String if (!staging.compareAndSet(false, true)) return Result.failure("DEPLOYMENT_FAILED", "Another deployment is still staging"); try { validate(task); - if (!credentialEndpointAllowed(endpoint, directLocalHosted)) { + if (!deploymentEndpointAllowed(endpoint)) { return Result.failure("INSECURE_ENDPOINT", - "Verified update staging requires HTTPS unless Control is hosted directly on this node"); + "Verified update staging requires an HTTP or HTTPS Control endpoint"); } if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before download"); if (alreadyStaged(task)) return Result.restartRequired(); @@ -444,29 +444,27 @@ private static void forceDirectory(Path directory) throws IOException { DurableFiles.forceDirectory(directory); } - /** True when a deployment bearer credential may be sent to this Control endpoint. */ - public static boolean credentialEndpointAllowed(URI endpoint, boolean directLocalHosted) { + /** + * True when the configured Control transport can carry a deployment request. + * + *

HTTP remains supported for trusted private networks for compatibility with the + * rest of the Control connector. Callers warn operators because HTTPS is strongly + * recommended whenever traffic leaves the local process.

+ */ + public static boolean deploymentEndpointAllowed(URI endpoint) { if (endpoint == null) return false; - if ("https".equalsIgnoreCase(endpoint.getScheme())) return true; - return directLocalHosted && "http".equalsIgnoreCase(endpoint.getScheme()) - && isLoopbackHost(endpoint.getHost()); + return "https".equalsIgnoreCase(endpoint.getScheme()) + || "http".equalsIgnoreCase(endpoint.getScheme()); } - private static boolean isLoopbackHost(String host) { - if (host == null) return false; - String normalized = host; - if (normalized.length() >= 2 && normalized.charAt(0) == '[' - && normalized.charAt(normalized.length() - 1) == ']') { - normalized = normalized.substring(1, normalized.length() - 1); - } - if ("localhost".equalsIgnoreCase(normalized) || "::1".equalsIgnoreCase(normalized) - || "0:0:0:0:0:0:0:1".equalsIgnoreCase(normalized)) return true; - String[] octets = normalized.split("\\.", -1); - if (octets.length != 4 || !"127".equals(octets[0])) return false; - for (int index = 1; index < octets.length; index++) { - if (!octets[index].matches("[0-9]{1,3}") || Integer.parseInt(octets[index]) > 255) return false; - } - return true; + /** @deprecated Use {@link #deploymentEndpointAllowed(URI)}. */ + @Deprecated + public static boolean credentialEndpointAllowed(URI endpoint, boolean directLocalHosted) { + return deploymentEndpointAllowed(endpoint); + } + + public static boolean usesUnencryptedHttp(URI endpoint) { + return endpoint != null && "http".equalsIgnoreCase(endpoint.getScheme()); } private static MessageDigest sha256() { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java index cd8b8c27f..c110e2801 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java @@ -259,12 +259,15 @@ public static ControlConnector create(VotingPluginProxy proxy) throws IOExceptio config.getControlHostedStartupTimeoutSeconds(), config.getControlHostedDownloadTimeoutSeconds()); boolean directLocalDeploymentEndpoint = HostedControlManager.isDirectLocalEndpoint( settings.endpoint().toString(), hosted); - boolean deploymentEndpointAllowed = PluginDeploymentService.credentialEndpointAllowed( - settings.endpoint(), directLocalDeploymentEndpoint); + boolean deploymentEndpointAllowed = PluginDeploymentService.deploymentEndpointAllowed(settings.endpoint()); PluginDeploymentService deployments = deploymentRouteCurrent && deploymentEndpointAllowed ? prepareDeployment(proxy) : null; if (deploymentRouteCurrent && !deploymentEndpointAllowed) { - proxy.log("[Control] Plugin deployment staging requires HTTPS unless Control is hosted directly on this node"); + proxy.log("[Control] Plugin deployment staging requires an HTTP or HTTPS Control endpoint"); + } + if (deployments != null && PluginDeploymentService.usesUnencryptedHttp(settings.endpoint())) { + proxy.log("[Control] Verified plugin staging is enabled over unencrypted HTTP. " + + "HTTPS is strongly recommended because node credentials and plugin artifacts cross this connection"); } HttpClient deploymentHttp = deployments == null ? null : HttpClient.newBuilder() .connectTimeout(Duration.ofMillis(settings.connectTimeoutMillis())) diff --git a/VotingPlugin/src/main/resources/Config.yml b/VotingPlugin/src/main/resources/Config.yml index 37b251af0..1725ce030 100644 --- a/VotingPlugin/src/main/resources/Config.yml +++ b/VotingPlugin/src/main/resources/Config.yml @@ -1197,7 +1197,8 @@ Control: Enabled: false # Blank reuses BungeeSettings.Server, which must be unique for every backend. NodeId: '' - # For a proxy-hosted Control, use the proxy VM/private IP. Loopback is accepted only for Control hosted by this backend. + # For a proxy-hosted Control, use the proxy VM/private IP. HTTP works on trusted private networks; + # HTTPS is strongly recommended because connector credentials and staged plugin artifacts cross this connection. Endpoint: 'http://127.0.0.1:8080' # VotingPlugin automatically generates this local credential only after confirming it can enroll through # the hosted Control on this server or the configured authenticated proxy transport. diff --git a/VotingPlugin/src/main/resources/bungeeconfig.yml b/VotingPlugin/src/main/resources/bungeeconfig.yml index bcdc73213..28ca486de 100644 --- a/VotingPlugin/src/main/resources/bungeeconfig.yml +++ b/VotingPlugin/src/main/resources/bungeeconfig.yml @@ -511,6 +511,8 @@ MultiProxyServers: # Optional local-first VotingPlugin Control discovery. Missing keys preserve the disabled default. Control: Enabled: false + # HTTP works on trusted private networks. HTTPS is strongly recommended because connector credentials + # and staged plugin artifacts cross this connection. Endpoint: 'http://127.0.0.1:8080' # Blank reuses ProxyServerName. NodeId: '' diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java index 423e46db8..de8643761 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java @@ -133,19 +133,26 @@ class PluginDeploymentServiceTest { "a deleted or quarantined update must be staged again before restart"); } - @Test void credentialedDeploymentRequiresHttpsUnlessSameNodeHostedHttpWasProven() { + @Test void credentialedDeploymentSupportsConfiguredHttpButIdentifiesItAsUnencrypted() { + assertTrue(PluginDeploymentService.deploymentEndpointAllowed( + java.net.URI.create("https://control.example.test"))); + assertTrue(PluginDeploymentService.deploymentEndpointAllowed( + java.net.URI.create("http://192.0.2.10:8080"))); + assertTrue(PluginDeploymentService.deploymentEndpointAllowed( + java.net.URI.create("http://127.0.0.1:8080"))); + assertTrue(PluginDeploymentService.deploymentEndpointAllowed( + java.net.URI.create("http://[::1]:8080"))); + assertTrue(PluginDeploymentService.deploymentEndpointAllowed( + java.net.URI.create("http://127.example.com:8080"))); + assertFalse(PluginDeploymentService.deploymentEndpointAllowed( + java.net.URI.create("ftp://control.example.test"))); + assertFalse(PluginDeploymentService.deploymentEndpointAllowed(null)); assertTrue(PluginDeploymentService.credentialEndpointAllowed( - java.net.URI.create("https://control.example.test"), false)); - assertFalse(PluginDeploymentService.credentialEndpointAllowed( java.net.URI.create("http://192.0.2.10:8080"), false)); - assertFalse(PluginDeploymentService.credentialEndpointAllowed( - java.net.URI.create("http://127.0.0.1:8080"), false)); - assertTrue(PluginDeploymentService.credentialEndpointAllowed( - java.net.URI.create("http://127.0.0.1:8080"), true)); - assertTrue(PluginDeploymentService.credentialEndpointAllowed( - java.net.URI.create("http://[::1]:8080"), true)); - assertFalse(PluginDeploymentService.credentialEndpointAllowed( - java.net.URI.create("http://127.example.com:8080"), true)); + assertTrue(PluginDeploymentService.usesUnencryptedHttp( + java.net.URI.create("http://192.0.2.10:8080"))); + assertFalse(PluginDeploymentService.usesUnencryptedHttp( + java.net.URI.create("https://control.example.test"))); } @Test void backendIgnoresMatchingMarkerOnlyWhenTargetIsValidThenRestagesWhenCorrupted() throws Exception { diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index c813b9c08..21cf63514 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -194,10 +194,11 @@ and never restarts a proxy or backend automatically. A node advertises it only w - the connector is the currently enabled Control route, not a recovery-only connector draining an older durable result; - a safe local staging target was prepared; -- the Control endpoint is HTTPS, or it is the already-proven direct same-node hosted HTTP listener. +- the Control endpoint uses HTTP or HTTPS. -Arbitrary private-network HTTP does not qualify for deployment because the artifact request carries the node bearer -credential. The shared staging service enforces the same transport rule again before sending that credential. +HTTP remains supported for trusted private networks so deployment has the same transport compatibility as the rest of +the connector. HTTPS is strongly recommended because the artifact request carries the node bearer credential and the +plugin artifact in transit. Connectors log that recommendation at startup when staging is enabled over HTTP. Control leases deployment work separately from configuration operations: diff --git a/docs/control-connector.md b/docs/control-connector.md index d88d2c9d2..d62189fda 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -212,10 +212,10 @@ restart. This capability is deliberately separate from configuration control and VotingPlugin never hot-reloads itself and never restarts the server or proxy automatically. Deployment is available only on the currently enabled Control route. Recovery-only connectors that exist solely to -acknowledge an older durable result never advertise or poll this capability. The node also requires a credential-safe -artifact transport: HTTPS is accepted generally; HTTP is accepted only when the existing hosted-Control checks prove the -endpoint is the direct same-node listener. A LAN/private HTTP endpoint may still be used for ordinary Control operations, -but it is intentionally ineligible for credentialed plugin-JAR staging. +acknowledge an older durable result never advertise or poll this capability. Both configured HTTP and HTTPS endpoints +can stage artifacts so trusted private-network installations retain the same compatibility as ordinary Control +operations. HTTPS is strongly recommended because the artifact request carries the node bearer credential and plugin +artifact in transit; connectors emit a startup warning when verified staging is enabled over HTTP. Control leases deployment work through `POST /api/v1/nodes/{nodeId}/deployments`. The node downloads the artifact through the matching deployment artifact endpoint with its bearer credential plus exact session and attempt headers, then From 9c8a939786127e40a3854ef040915f10bb75acde Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:27:39 -0600 Subject: [PATCH 2/5] Limit HTTP staging to private network addresses --- .../control/BackendControlConnector.java | 2 +- .../control/PluginDeploymentService.java | 32 ++++++++++++++++--- .../proxy/control/ControlConnector.java | 2 +- VotingPlugin/src/main/resources/Config.yml | 2 +- .../src/main/resources/bungeeconfig.yml | 2 +- .../control/PluginDeploymentServiceTest.java | 22 ++++++++++--- docs/control-agent-contract.md | 11 ++++--- docs/control-connector.md | 8 ++--- 8 files changed, 58 insertions(+), 23 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index 7348e2704..5150253ea 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -151,7 +151,7 @@ private BackendControlConnector(VotingPluginMain plugin, Path dataDirectory, Set plugin.getLogger().warning("[Control] Plugin deployment staging is unavailable; capability not advertised"); } } else if (!recovering && !deploymentEndpointAllowed) { - plugin.getLogger().warning("[Control] Plugin deployment staging requires an HTTP or HTTPS Control endpoint"); + plugin.getLogger().warning("[Control] Plugin deployment staging requires HTTPS or a literal private-network HTTP endpoint"); } if (prepared != null && PluginDeploymentService.usesUnencryptedHttp(settings.endpoint())) { plugin.getLogger().warning("[Control] Verified plugin staging is enabled over unencrypted HTTP. " diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java index 161ac120b..6f906e9b8 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.io.InputStream; +import java.net.InetAddress; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -124,7 +125,7 @@ public Result deploy(Task task, URI endpoint, boolean directLocalHosted, String validate(task); if (!deploymentEndpointAllowed(endpoint)) { return Result.failure("INSECURE_ENDPOINT", - "Verified update staging requires an HTTP or HTTPS Control endpoint"); + "Verified update staging requires HTTPS or a literal private-network HTTP endpoint"); } if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before download"); if (alreadyStaged(task)) return Result.restartRequired(); @@ -447,14 +448,15 @@ private static void forceDirectory(Path directory) throws IOException { /** * True when the configured Control transport can carry a deployment request. * - *

HTTP remains supported for trusted private networks for compatibility with the - * rest of the Control connector. Callers warn operators because HTTPS is strongly - * recommended whenever traffic leaves the local process.

+ *

HTTP remains supported only for literal loopback, link-local, and private + * network addresses. Public addresses and hostnames require HTTPS. Callers warn + * operators because HTTPS is strongly recommended whenever traffic leaves the + * local process.

*/ public static boolean deploymentEndpointAllowed(URI endpoint) { if (endpoint == null) return false; return "https".equalsIgnoreCase(endpoint.getScheme()) - || "http".equalsIgnoreCase(endpoint.getScheme()); + || "http".equalsIgnoreCase(endpoint.getScheme()) && isLocalNetworkAddress(endpoint.getHost()); } /** @deprecated Use {@link #deploymentEndpointAllowed(URI)}. */ @@ -467,6 +469,26 @@ public static boolean usesUnencryptedHttp(URI endpoint) { return endpoint != null && "http".equalsIgnoreCase(endpoint.getScheme()); } + private static boolean isLocalNetworkAddress(String host) { + if (host == null || host.isBlank()) return false; + String literal = host; + if (literal.length() >= 2 && literal.charAt(0) == '[' && literal.charAt(literal.length() - 1) == ']') { + literal = literal.substring(1, literal.length() - 1); + } + int zone = literal.indexOf('%'); + if (zone >= 0) literal = literal.substring(0, zone); + if (!(literal.indexOf(':') >= 0 || literal.matches("[0-9]{1,3}(\\.[0-9]{1,3}){3}"))) return false; + try { + InetAddress address = InetAddress.getByName(literal); + byte[] bytes = address.getAddress(); + boolean uniqueLocalV6 = bytes.length == 16 && (bytes[0] & 0xfe) == 0xfc; + return address.isLoopbackAddress() || address.isSiteLocalAddress() + || address.isLinkLocalAddress() || uniqueLocalV6; + } catch (Exception invalid) { + return false; + } + } + private static MessageDigest sha256() { try { return MessageDigest.getInstance("SHA-256"); } catch (java.security.NoSuchAlgorithmException failure) { throw new IllegalStateException("SHA-256 is unavailable", failure); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java index c110e2801..2ed47ee26 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java @@ -263,7 +263,7 @@ public static ControlConnector create(VotingPluginProxy proxy) throws IOExceptio PluginDeploymentService deployments = deploymentRouteCurrent && deploymentEndpointAllowed ? prepareDeployment(proxy) : null; if (deploymentRouteCurrent && !deploymentEndpointAllowed) { - proxy.log("[Control] Plugin deployment staging requires an HTTP or HTTPS Control endpoint"); + proxy.log("[Control] Plugin deployment staging requires HTTPS or a literal private-network HTTP endpoint"); } if (deployments != null && PluginDeploymentService.usesUnencryptedHttp(settings.endpoint())) { proxy.log("[Control] Verified plugin staging is enabled over unencrypted HTTP. " diff --git a/VotingPlugin/src/main/resources/Config.yml b/VotingPlugin/src/main/resources/Config.yml index 1725ce030..badffdeed 100644 --- a/VotingPlugin/src/main/resources/Config.yml +++ b/VotingPlugin/src/main/resources/Config.yml @@ -1197,7 +1197,7 @@ Control: Enabled: false # Blank reuses BungeeSettings.Server, which must be unique for every backend. NodeId: '' - # For a proxy-hosted Control, use the proxy VM/private IP. HTTP works on trusted private networks; + # For a proxy-hosted Control, use the proxy VM/private IP. HTTP staging works only with a literal private IP; # HTTPS is strongly recommended because connector credentials and staged plugin artifacts cross this connection. Endpoint: 'http://127.0.0.1:8080' # VotingPlugin automatically generates this local credential only after confirming it can enroll through diff --git a/VotingPlugin/src/main/resources/bungeeconfig.yml b/VotingPlugin/src/main/resources/bungeeconfig.yml index 28ca486de..3a823c190 100644 --- a/VotingPlugin/src/main/resources/bungeeconfig.yml +++ b/VotingPlugin/src/main/resources/bungeeconfig.yml @@ -511,7 +511,7 @@ MultiProxyServers: # Optional local-first VotingPlugin Control discovery. Missing keys preserve the disabled default. Control: Enabled: false - # HTTP works on trusted private networks. HTTPS is strongly recommended because connector credentials + # HTTP staging works only with a literal loopback/private-network IP. HTTPS is strongly recommended because connector credentials # and staged plugin artifacts cross this connection. Endpoint: 'http://127.0.0.1:8080' # Blank reuses ProxyServerName. diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java index de8643761..7c4380a60 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java @@ -133,24 +133,36 @@ class PluginDeploymentServiceTest { "a deleted or quarantined update must be staged again before restart"); } - @Test void credentialedDeploymentSupportsConfiguredHttpButIdentifiesItAsUnencrypted() { + @Test void credentialedDeploymentAllowsHttpsAndLiteralPrivateNetworkHttp() { assertTrue(PluginDeploymentService.deploymentEndpointAllowed( java.net.URI.create("https://control.example.test"))); assertTrue(PluginDeploymentService.deploymentEndpointAllowed( - java.net.URI.create("http://192.0.2.10:8080"))); + java.net.URI.create("http://192.168.0.50:8080"))); + assertTrue(PluginDeploymentService.deploymentEndpointAllowed( + java.net.URI.create("http://10.20.30.40:8080"))); + assertTrue(PluginDeploymentService.deploymentEndpointAllowed( + java.net.URI.create("http://172.31.4.5:8080"))); assertTrue(PluginDeploymentService.deploymentEndpointAllowed( java.net.URI.create("http://127.0.0.1:8080"))); assertTrue(PluginDeploymentService.deploymentEndpointAllowed( java.net.URI.create("http://[::1]:8080"))); assertTrue(PluginDeploymentService.deploymentEndpointAllowed( - java.net.URI.create("http://127.example.com:8080"))); + java.net.URI.create("http://[fd00::50]:8080"))); + assertFalse(PluginDeploymentService.deploymentEndpointAllowed( + java.net.URI.create("http://192.0.2.10:8080"))); + assertFalse(PluginDeploymentService.deploymentEndpointAllowed( + java.net.URI.create("http://8.8.8.8:8080"))); + assertFalse(PluginDeploymentService.deploymentEndpointAllowed( + java.net.URI.create("http://localhost:8080"))); + assertFalse(PluginDeploymentService.deploymentEndpointAllowed( + java.net.URI.create("http://control.example.test:8080"))); assertFalse(PluginDeploymentService.deploymentEndpointAllowed( java.net.URI.create("ftp://control.example.test"))); assertFalse(PluginDeploymentService.deploymentEndpointAllowed(null)); assertTrue(PluginDeploymentService.credentialEndpointAllowed( - java.net.URI.create("http://192.0.2.10:8080"), false)); + java.net.URI.create("http://192.168.0.50:8080"), false)); assertTrue(PluginDeploymentService.usesUnencryptedHttp( - java.net.URI.create("http://192.0.2.10:8080"))); + java.net.URI.create("http://192.168.0.50:8080"))); assertFalse(PluginDeploymentService.usesUnencryptedHttp( java.net.URI.create("https://control.example.test"))); } diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index 21cf63514..bab410063 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -194,11 +194,11 @@ and never restarts a proxy or backend automatically. A node advertises it only w - the connector is the currently enabled Control route, not a recovery-only connector draining an older durable result; - a safe local staging target was prepared; -- the Control endpoint uses HTTP or HTTPS. +- the Control endpoint uses HTTPS, or HTTP with a literal loopback/link-local/private-network address. -HTTP remains supported for trusted private networks so deployment has the same transport compatibility as the rest of -the connector. HTTPS is strongly recommended because the artifact request carries the node bearer credential and the -plugin artifact in transit. Connectors log that recommendation at startup when staging is enabled over HTTP. +HTTP remains supported for directly addressed trusted private networks. Hostnames and public IP addresses require HTTPS, +which is strongly recommended because the artifact request carries the node bearer credential and plugin artifact in +transit. Connectors log that recommendation at startup when staging is enabled over HTTP. Control leases deployment work separately from configuration operations: @@ -219,7 +219,8 @@ X-Node-Session: X-Deployment-Attempt: ``` -The node independently verifies the exact size and SHA-256, bounded ZIP/JAR structure, root `plugin.yml`, and +Control verifies the uploaded artifact before leasing it. The node independently re-verifies the exact size and SHA-256, +bounded ZIP/JAR structure, root `plugin.yml`, and `name: VotingPlugin` before publication. Bukkit nodes stage to the server update folder; proxy nodes atomically replace their discovered plugin JAR only after creating a durable `.control-backup`. A small durable `.control-deployment` marker is published before the verified target is moved into place, so a lost result diff --git a/docs/control-connector.md b/docs/control-connector.md index d62189fda..9511ba60b 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -212,14 +212,14 @@ restart. This capability is deliberately separate from configuration control and VotingPlugin never hot-reloads itself and never restarts the server or proxy automatically. Deployment is available only on the currently enabled Control route. Recovery-only connectors that exist solely to -acknowledge an older durable result never advertise or poll this capability. Both configured HTTP and HTTPS endpoints -can stage artifacts so trusted private-network installations retain the same compatibility as ordinary Control -operations. HTTPS is strongly recommended because the artifact request carries the node bearer credential and plugin +acknowledge an older durable result never advertise or poll this capability. HTTPS endpoints can stage generally. HTTP +staging is limited to literal loopback, link-local, and private-network endpoint addresses; hostnames and public IPs do +not qualify. HTTPS is strongly recommended because the artifact request carries the node bearer credential and plugin artifact in transit; connectors emit a startup warning when verified staging is enabled over HTTP. Control leases deployment work through `POST /api/v1/nodes/{nodeId}/deployments`. The node downloads the artifact through the matching deployment artifact endpoint with its bearer credential plus exact session and attempt headers, then -independently verifies the 64 MiB size bound, SHA-256, JAR structure, and root `plugin.yml` identity. Bukkit stages the +independently re-verifies Control's 64 MiB size bound, SHA-256, JAR structure, and root `plugin.yml` identity. Bukkit stages the verified JAR in the configured update folder; BungeeCord/Velocity retain a durable backup before atomically replacing the running plugin JAR on disk. A durable deployment marker makes lost result acknowledgements idempotent, including the post-restart Bukkit state where the server has already consumed the staged update JAR. From 426197f0c714215275623d92b13884ebff18c966 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:36:19 -0600 Subject: [PATCH 3/5] Preserve proven localhost deployment staging --- .../control/BackendControlConnector.java | 3 ++- .../control/PluginDeploymentService.java | 16 +++++++++++----- .../proxy/control/ControlConnector.java | 3 ++- .../control/PluginDeploymentServiceTest.java | 6 ++++++ 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index 5150253ea..cc5380007 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -142,7 +142,8 @@ private BackendControlConnector(VotingPluginMain plugin, Path dataDirectory, Set directLocalDeploymentEndpoint = HostedControlManager.isDirectLocalEndpoint( settings.endpoint().toString(), hostedConfiguration); PluginDeploymentService prepared = null; - boolean deploymentEndpointAllowed = PluginDeploymentService.deploymentEndpointAllowed(settings.endpoint()); + boolean deploymentEndpointAllowed = PluginDeploymentService.deploymentEndpointAllowed( + settings.endpoint(), directLocalDeploymentEndpoint); if (!recovering && deploymentEndpointAllowed) { try { prepared = PluginDeploymentService.backend(plugin.getServer().getUpdateFolderFile().toPath(), diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java index 6f906e9b8..a14b618c2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java @@ -123,9 +123,9 @@ public Result deploy(Task task, URI endpoint, boolean directLocalHosted, String if (!staging.compareAndSet(false, true)) return Result.failure("DEPLOYMENT_FAILED", "Another deployment is still staging"); try { validate(task); - if (!deploymentEndpointAllowed(endpoint)) { + if (!deploymentEndpointAllowed(endpoint, directLocalHosted)) { return Result.failure("INSECURE_ENDPOINT", - "Verified update staging requires HTTPS or a literal private-network HTTP endpoint"); + "Verified update staging requires HTTPS, a literal private-network HTTP endpoint, or proven same-node localhost hosting"); } if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before download"); if (alreadyStaged(task)) return Result.restartRequired(); @@ -454,15 +454,21 @@ private static void forceDirectory(Path directory) throws IOException { * local process.

*/ public static boolean deploymentEndpointAllowed(URI endpoint) { + return deploymentEndpointAllowed(endpoint, false); + } + + public static boolean deploymentEndpointAllowed(URI endpoint, boolean directLocalHosted) { if (endpoint == null) return false; return "https".equalsIgnoreCase(endpoint.getScheme()) - || "http".equalsIgnoreCase(endpoint.getScheme()) && isLocalNetworkAddress(endpoint.getHost()); + || "http".equalsIgnoreCase(endpoint.getScheme()) + && (isLocalNetworkAddress(endpoint.getHost()) + || directLocalHosted && "localhost".equalsIgnoreCase(endpoint.getHost())); } - /** @deprecated Use {@link #deploymentEndpointAllowed(URI)}. */ + /** @deprecated Use {@link #deploymentEndpointAllowed(URI, boolean)}. */ @Deprecated public static boolean credentialEndpointAllowed(URI endpoint, boolean directLocalHosted) { - return deploymentEndpointAllowed(endpoint); + return deploymentEndpointAllowed(endpoint, directLocalHosted); } public static boolean usesUnencryptedHttp(URI endpoint) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java index 2ed47ee26..8ef39a28d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java @@ -259,7 +259,8 @@ public static ControlConnector create(VotingPluginProxy proxy) throws IOExceptio config.getControlHostedStartupTimeoutSeconds(), config.getControlHostedDownloadTimeoutSeconds()); boolean directLocalDeploymentEndpoint = HostedControlManager.isDirectLocalEndpoint( settings.endpoint().toString(), hosted); - boolean deploymentEndpointAllowed = PluginDeploymentService.deploymentEndpointAllowed(settings.endpoint()); + boolean deploymentEndpointAllowed = PluginDeploymentService.deploymentEndpointAllowed( + settings.endpoint(), directLocalDeploymentEndpoint); PluginDeploymentService deployments = deploymentRouteCurrent && deploymentEndpointAllowed ? prepareDeployment(proxy) : null; if (deploymentRouteCurrent && !deploymentEndpointAllowed) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java index 7c4380a60..3a3cdad22 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java @@ -154,6 +154,10 @@ class PluginDeploymentServiceTest { java.net.URI.create("http://8.8.8.8:8080"))); assertFalse(PluginDeploymentService.deploymentEndpointAllowed( java.net.URI.create("http://localhost:8080"))); + assertTrue(PluginDeploymentService.deploymentEndpointAllowed( + java.net.URI.create("http://localhost:8080"), true)); + assertFalse(PluginDeploymentService.deploymentEndpointAllowed( + java.net.URI.create("http://localhost:8080"), false)); assertFalse(PluginDeploymentService.deploymentEndpointAllowed( java.net.URI.create("http://control.example.test:8080"))); assertFalse(PluginDeploymentService.deploymentEndpointAllowed( @@ -161,6 +165,8 @@ class PluginDeploymentServiceTest { assertFalse(PluginDeploymentService.deploymentEndpointAllowed(null)); assertTrue(PluginDeploymentService.credentialEndpointAllowed( java.net.URI.create("http://192.168.0.50:8080"), false)); + assertTrue(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("http://localhost:8080"), true)); assertTrue(PluginDeploymentService.usesUnencryptedHttp( java.net.URI.create("http://192.168.0.50:8080"))); assertFalse(PluginDeploymentService.usesUnencryptedHttp( From 679ecd6b72b32958fc0eea0ef63d555fddc361d2 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:43:18 -0600 Subject: [PATCH 4/5] Document proven localhost deployment staging --- .../votingplugin/control/PluginDeploymentService.java | 5 +++-- VotingPlugin/src/main/resources/Config.yml | 3 ++- VotingPlugin/src/main/resources/bungeeconfig.yml | 3 ++- docs/control-agent-contract.md | 5 +++-- docs/control-connector.md | 7 ++++--- 5 files changed, 14 insertions(+), 9 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java index a14b618c2..95447742d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java @@ -448,8 +448,9 @@ private static void forceDirectory(Path directory) throws IOException { /** * True when the configured Control transport can carry a deployment request. * - *

HTTP remains supported only for literal loopback, link-local, and private - * network addresses. Public addresses and hostnames require HTTPS. Callers warn + *

HTTP remains supported for literal loopback, link-local, and private network + * addresses. The overload also permits {@code localhost} when direct local hosting + * is confirmed. Public addresses and other hostnames require HTTPS. Callers warn * operators because HTTPS is strongly recommended whenever traffic leaves the * local process.

*/ diff --git a/VotingPlugin/src/main/resources/Config.yml b/VotingPlugin/src/main/resources/Config.yml index badffdeed..1050b30c4 100644 --- a/VotingPlugin/src/main/resources/Config.yml +++ b/VotingPlugin/src/main/resources/Config.yml @@ -1197,7 +1197,8 @@ Control: Enabled: false # Blank reuses BungeeSettings.Server, which must be unique for every backend. NodeId: '' - # For a proxy-hosted Control, use the proxy VM/private IP. HTTP staging works only with a literal private IP; + # For a proxy-hosted Control, use the proxy VM/private IP. HTTP staging also accepts literal local/private IPs + # and localhost only when direct hosting on this same node is confirmed. # HTTPS is strongly recommended because connector credentials and staged plugin artifacts cross this connection. Endpoint: 'http://127.0.0.1:8080' # VotingPlugin automatically generates this local credential only after confirming it can enroll through diff --git a/VotingPlugin/src/main/resources/bungeeconfig.yml b/VotingPlugin/src/main/resources/bungeeconfig.yml index 3a823c190..95b30ea23 100644 --- a/VotingPlugin/src/main/resources/bungeeconfig.yml +++ b/VotingPlugin/src/main/resources/bungeeconfig.yml @@ -511,7 +511,8 @@ MultiProxyServers: # Optional local-first VotingPlugin Control discovery. Missing keys preserve the disabled default. Control: Enabled: false - # HTTP staging works only with a literal loopback/private-network IP. HTTPS is strongly recommended because connector credentials + # HTTP staging accepts literal local/private IPs, or localhost when direct hosting on this same node is confirmed. + # HTTPS is strongly recommended because connector credentials # and staged plugin artifacts cross this connection. Endpoint: 'http://127.0.0.1:8080' # Blank reuses ProxyServerName. diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index bab410063..1818ba149 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -194,9 +194,10 @@ and never restarts a proxy or backend automatically. A node advertises it only w - the connector is the currently enabled Control route, not a recovery-only connector draining an older durable result; - a safe local staging target was prepared; -- the Control endpoint uses HTTPS, or HTTP with a literal loopback/link-local/private-network address. +- the Control endpoint uses HTTPS, HTTP with a literal loopback/link-local/private-network address, or + `http://localhost` with confirmed direct local hosting on the same node. -HTTP remains supported for directly addressed trusted private networks. Hostnames and public IP addresses require HTTPS, +HTTP remains supported for directly addressed trusted private networks. Other hostnames and public IP addresses require HTTPS, which is strongly recommended because the artifact request carries the node bearer credential and plugin artifact in transit. Connectors log that recommendation at startup when staging is enabled over HTTP. diff --git a/docs/control-connector.md b/docs/control-connector.md index 9511ba60b..575fabe08 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -213,9 +213,10 @@ VotingPlugin never hot-reloads itself and never restarts the server or proxy aut Deployment is available only on the currently enabled Control route. Recovery-only connectors that exist solely to acknowledge an older durable result never advertise or poll this capability. HTTPS endpoints can stage generally. HTTP -staging is limited to literal loopback, link-local, and private-network endpoint addresses; hostnames and public IPs do -not qualify. HTTPS is strongly recommended because the artifact request carries the node bearer credential and plugin -artifact in transit; connectors emit a startup warning when verified staging is enabled over HTTP. +staging is limited to literal loopback, link-local, and private-network endpoint addresses, plus `localhost` when direct +local hosting on the same node is confirmed. Other hostnames and public IPs do not qualify. HTTPS is strongly recommended +because the artifact request carries the node bearer credential and plugin artifact in transit; connectors emit a startup +warning when verified staging is enabled over HTTP. Control leases deployment work through `POST /api/v1/nodes/{nodeId}/deployments`. The node downloads the artifact through the matching deployment artifact endpoint with its bearer credential plus exact session and attempt headers, then From d7126fd5f35290cdda4fdef76b5687de920ef198 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:52:01 -0600 Subject: [PATCH 5/5] Preserve credential endpoint eligibility contract --- .../control/PluginDeploymentService.java | 24 +++++++++++++++-- .../control/PluginDeploymentServiceTest.java | 26 ++++++++++++++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java index 95447742d..267a92d1e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java @@ -466,10 +466,13 @@ public static boolean deploymentEndpointAllowed(URI endpoint, boolean directLoca || directLocalHosted && "localhost".equalsIgnoreCase(endpoint.getHost())); } - /** @deprecated Use {@link #deploymentEndpointAllowed(URI, boolean)}. */ + /** @deprecated Retained for credential transport callers; use {@link #deploymentEndpointAllowed(URI, boolean)} only for deployment staging. */ @Deprecated public static boolean credentialEndpointAllowed(URI endpoint, boolean directLocalHosted) { - return deploymentEndpointAllowed(endpoint, directLocalHosted); + if (endpoint == null) return false; + if ("https".equalsIgnoreCase(endpoint.getScheme())) return true; + return directLocalHosted && "http".equalsIgnoreCase(endpoint.getScheme()) + && isLoopbackHost(endpoint.getHost()); } public static boolean usesUnencryptedHttp(URI endpoint) { @@ -496,6 +499,23 @@ private static boolean isLocalNetworkAddress(String host) { } } + private static boolean isLoopbackHost(String host) { + if (host == null) return false; + String normalized = host; + if (normalized.length() >= 2 && normalized.charAt(0) == '[' + && normalized.charAt(normalized.length() - 1) == ']') { + normalized = normalized.substring(1, normalized.length() - 1); + } + if ("localhost".equalsIgnoreCase(normalized) || "::1".equalsIgnoreCase(normalized) + || "0:0:0:0:0:0:0:1".equalsIgnoreCase(normalized)) return true; + String[] octets = normalized.split("\\.", -1); + if (octets.length != 4 || !"127".equals(octets[0])) return false; + for (int index = 1; index < octets.length; index++) { + if (!octets[index].matches("[0-9]{1,3}") || Integer.parseInt(octets[index]) > 255) return false; + } + return true; + } + private static MessageDigest sha256() { try { return MessageDigest.getInstance("SHA-256"); } catch (java.security.NoSuchAlgorithmException failure) { throw new IllegalStateException("SHA-256 is unavailable", failure); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java index 3a3cdad22..162d58f70 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java @@ -163,16 +163,34 @@ class PluginDeploymentServiceTest { assertFalse(PluginDeploymentService.deploymentEndpointAllowed( java.net.URI.create("ftp://control.example.test"))); assertFalse(PluginDeploymentService.deploymentEndpointAllowed(null)); - assertTrue(PluginDeploymentService.credentialEndpointAllowed( - java.net.URI.create("http://192.168.0.50:8080"), false)); - assertTrue(PluginDeploymentService.credentialEndpointAllowed( - java.net.URI.create("http://localhost:8080"), true)); assertTrue(PluginDeploymentService.usesUnencryptedHttp( java.net.URI.create("http://192.168.0.50:8080"))); assertFalse(PluginDeploymentService.usesUnencryptedHttp( java.net.URI.create("https://control.example.test"))); } + @Test void credentialEndpointRetainsTheOriginalHttpsOrProvenLoopbackRule() { + assertTrue(PluginDeploymentService.deploymentEndpointAllowed( + java.net.URI.create("http://192.168.0.50:8080"), false)); + assertFalse(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("http://192.168.0.50:8080"), false)); + assertFalse(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("http://192.168.0.50:8080"), true)); + assertFalse(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("http://169.254.1.2:8080"), true)); + assertFalse(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("http://localhost:8080"), false)); + assertTrue(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("http://localhost:8080"), true)); + assertTrue(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("http://127.0.0.2:8080"), true)); + assertTrue(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("http://[::1]:8080"), true)); + assertTrue(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("https://control.example.test"), false)); + assertFalse(PluginDeploymentService.credentialEndpointAllowed(null, true)); + } + @Test void backendIgnoresMatchingMarkerOnlyWhenTargetIsValidThenRestagesWhenCorrupted() throws Exception { byte[] artifact = jar("name: VotingPlugin\n"); PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update"), Path.of("VotingPlugin.jar"));