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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -444,14 +445,60 @@ 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.
*
* <p>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.</p>
*/
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;
return directLocalHosted && "http".equalsIgnoreCase(endpoint.getScheme())
&& 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;
Comment thread
BenCodez marked this conversation as resolved.
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
4 changes: 3 additions & 1 deletion VotingPlugin/src/main/resources/Config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions VotingPlugin/src/main/resources/bungeeconfig.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ''
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 7 additions & 4 deletions docs/control-agent-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -218,7 +220,8 @@ X-Node-Session: <connector-session-uuid>
X-Deployment-Attempt: <attemptId>
```

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
Expand Down
11 changes: 6 additions & 5 deletions docs/control-connector.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading