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..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,7 @@ private BackendControlConnector(VotingPluginMain plugin, Path dataDirectory, Set directLocalDeploymentEndpoint = HostedControlManager.isDirectLocalEndpoint( settings.endpoint().toString(), hostedConfiguration); PluginDeploymentService prepared = null; - boolean deploymentEndpointAllowed = PluginDeploymentService.credentialEndpointAllowed( + boolean deploymentEndpointAllowed = PluginDeploymentService.deploymentEndpointAllowed( settings.endpoint(), directLocalDeploymentEndpoint); if (!recovering && deploymentEndpointAllowed) { try { @@ -152,7 +152,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 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. " + + "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..267a92d1e 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; @@ -122,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 (!credentialEndpointAllowed(endpoint, directLocalHosted)) { + if (!deploymentEndpointAllowed(endpoint, directLocalHosted)) { return Result.failure("INSECURE_ENDPOINT", - "Verified update staging requires HTTPS unless Control is hosted directly on this node"); + "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(); @@ -444,7 +445,29 @@ private static void forceDirectory(Path directory) throws IOException { DurableFiles.forceDirectory(directory); } - /** True when a deployment bearer credential may be sent to this Control endpoint. */ + /** + * True when the configured Control transport can carry a deployment request. + * + *

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.

+ */ + 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()) + || directLocalHosted && "localhost".equalsIgnoreCase(endpoint.getHost())); + } + + /** @deprecated Retained for credential transport callers; use {@link #deploymentEndpointAllowed(URI, boolean)} only for deployment staging. */ + @Deprecated public static boolean credentialEndpointAllowed(URI endpoint, boolean directLocalHosted) { if (endpoint == null) return false; if ("https".equalsIgnoreCase(endpoint.getScheme())) return true; @@ -452,6 +475,30 @@ public static boolean credentialEndpointAllowed(URI endpoint, boolean directLoca && isLoopbackHost(endpoint.getHost()); } + 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 boolean isLoopbackHost(String host) { if (host == null) return false; String normalized = host; 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..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,12 +259,16 @@ public static ControlConnector create(VotingPluginProxy proxy) throws IOExceptio config.getControlHostedStartupTimeoutSeconds(), config.getControlHostedDownloadTimeoutSeconds()); boolean directLocalDeploymentEndpoint = HostedControlManager.isDirectLocalEndpoint( settings.endpoint().toString(), hosted); - boolean deploymentEndpointAllowed = PluginDeploymentService.credentialEndpointAllowed( + boolean deploymentEndpointAllowed = PluginDeploymentService.deploymentEndpointAllowed( settings.endpoint(), directLocalDeploymentEndpoint); 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 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. " + + "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..1050b30c4 100644 --- a/VotingPlugin/src/main/resources/Config.yml +++ b/VotingPlugin/src/main/resources/Config.yml @@ -1197,7 +1197,9 @@ 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 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 # 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..95b30ea23 100644 --- a/VotingPlugin/src/main/resources/bungeeconfig.yml +++ b/VotingPlugin/src/main/resources/bungeeconfig.yml @@ -511,6 +511,9 @@ MultiProxyServers: # Optional local-first VotingPlugin Control discovery. Missing keys preserve the disabled default. Control: Enabled: false + # 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. 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..162d58f70 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,62 @@ class PluginDeploymentServiceTest { "a deleted or quarantined update must be staged again before restart"); } - @Test void credentialedDeploymentRequiresHttpsUnlessSameNodeHostedHttpWasProven() { - assertTrue(PluginDeploymentService.credentialEndpointAllowed( - java.net.URI.create("https://control.example.test"), false)); + @Test void credentialedDeploymentAllowsHttpsAndLiteralPrivateNetworkHttp() { + assertTrue(PluginDeploymentService.deploymentEndpointAllowed( + java.net.URI.create("https://control.example.test"))); + assertTrue(PluginDeploymentService.deploymentEndpointAllowed( + 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://[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"))); + 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( + java.net.URI.create("ftp://control.example.test"))); + assertFalse(PluginDeploymentService.deploymentEndpointAllowed(null)); + 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.0.2.10:8080"), false)); + java.net.URI.create("http://192.168.0.50:8080"), true)); assertFalse(PluginDeploymentService.credentialEndpointAllowed( - java.net.URI.create("http://127.0.0.1:8080"), false)); + 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.1:8080"), true)); + java.net.URI.create("http://127.0.0.2: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.credentialEndpointAllowed( + java.net.URI.create("https://control.example.test"), false)); + assertFalse(PluginDeploymentService.credentialEndpointAllowed(null, true)); } @Test void backendIgnoresMatchingMarkerOnlyWhenTargetIsValidThenRestagesWhenCorrupted() throws Exception { diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index c813b9c08..1818ba149 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -194,10 +194,12 @@ 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 HTTPS, HTTP with a literal loopback/link-local/private-network address, or + `http://localhost` with confirmed direct local hosting on the same node. -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 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. Control leases deployment work separately from configuration operations: @@ -218,7 +220,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 d88d2c9d2..575fabe08 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -212,14 +212,15 @@ 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. HTTPS endpoints can stage generally. 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 -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.