diff --git a/.mvn/jvm.config b/.mvn/jvm.config index 88f52a624..a40ae9aa5 100644 --- a/.mvn/jvm.config +++ b/.mvn/jvm.config @@ -1 +1 @@ --Xmx256m -XX:+HeapDumpOnOutOfMemoryError -XX:+TieredCompilation -XX:TieredStopAtLevel=1 \ No newline at end of file +-Xmx384m -XX:+HeapDumpOnOutOfMemoryError -XX:+TieredCompilation -XX:TieredStopAtLevel=1 diff --git a/pom.xml b/pom.xml index f6bf82897..4dbf4e602 100644 --- a/pom.xml +++ b/pom.xml @@ -66,9 +66,7 @@ THE SOFTWARE. Low false 1.79 - -Xms256M -Xmx256M -XX:+TieredCompilation -XX:TieredStopAtLevel=1 - - 11 + -Xms384M -Xmx384M -XX:+TieredCompilation -XX:TieredStopAtLevel=1 diff --git a/src/main/java/hudson/remoting/Engine.java b/src/main/java/hudson/remoting/Engine.java index 2850b794b..1ac08987a 100644 --- a/src/main/java/hudson/remoting/Engine.java +++ b/src/main/java/hudson/remoting/Engine.java @@ -23,91 +23,43 @@ */ package hudson.remoting; +import static org.jenkinsci.remoting.util.SSLUtils.getSSLSocketFactory; + import edu.umd.cs.findbugs.annotations.CheckForNull; import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; -import jakarta.websocket.ClientEndpointConfig; -import jakarta.websocket.CloseReason; -import jakarta.websocket.ContainerProvider; -import jakarta.websocket.Endpoint; -import jakarta.websocket.EndpointConfig; -import jakarta.websocket.HandshakeResponse; -import jakarta.websocket.Session; -import jakarta.websocket.WebSocketContainer; import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; import java.io.IOException; -import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.Socket; -import java.net.URI; import java.net.URL; -import java.nio.ByteBuffer; -import java.nio.charset.StandardCharsets; import java.nio.file.Path; -import java.security.AccessController; -import java.security.KeyManagementException; -import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.security.NoSuchProviderException; -import java.security.PrivilegedActionException; -import java.security.PrivilegedExceptionAction; -import java.security.SecureRandom; -import java.security.UnrecoverableKeyException; -import java.security.cert.CertificateException; import java.security.cert.X509Certificate; -import java.security.interfaces.RSAPublicKey; import java.time.Duration; -import java.time.Instant; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Base64; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; import java.util.logging.Logger; import java.util.stream.Collectors; import javax.net.ssl.HostnameVerifier; -import javax.net.ssl.KeyManagerFactory; -import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; import net.jcip.annotations.NotThreadSafe; -import org.glassfish.tyrus.client.ClientManager; -import org.glassfish.tyrus.client.ClientProperties; -import org.glassfish.tyrus.client.SslEngineConfigurator; -import org.jenkinsci.remoting.engine.Jnlp4ConnectionState; -import org.jenkinsci.remoting.engine.JnlpAgentEndpoint; +import org.jenkinsci.remoting.engine.EndpointConnector; +import org.jenkinsci.remoting.engine.EndpointConnectorData; +import org.jenkinsci.remoting.engine.InboundTCPConnector; import org.jenkinsci.remoting.engine.JnlpAgentEndpointConfigurator; import org.jenkinsci.remoting.engine.JnlpAgentEndpointResolver; -import org.jenkinsci.remoting.engine.JnlpConnectionState; -import org.jenkinsci.remoting.engine.JnlpConnectionStateListener; import org.jenkinsci.remoting.engine.JnlpEndpointResolver; -import org.jenkinsci.remoting.engine.JnlpProtocolHandler; -import org.jenkinsci.remoting.engine.JnlpProtocolHandlerFactory; +import org.jenkinsci.remoting.engine.WebSocketConnector; import org.jenkinsci.remoting.engine.WorkDirManager; -import org.jenkinsci.remoting.protocol.IOHub; import org.jenkinsci.remoting.protocol.cert.BlindTrustX509ExtendedTrustManager; import org.jenkinsci.remoting.protocol.cert.DelegatingX509ExtendedTrustManager; -import org.jenkinsci.remoting.protocol.cert.PublicKeyMatchingX509ExtendedTrustManager; -import org.jenkinsci.remoting.protocol.impl.ConnectionRefusalException; -import org.jenkinsci.remoting.util.DurationFormatter; -import org.jenkinsci.remoting.util.KeyUtils; -import org.jenkinsci.remoting.util.VersionNumber; import org.jenkinsci.remoting.util.https.NoCheckHostnameVerifier; -import org.jenkinsci.remoting.util.https.NoCheckTrustManager; -import org.kohsuke.accmod.Restricted; -import org.kohsuke.accmod.restrictions.NoExternalUse; /** * Agent engine that proactively connects to Jenkins controller. @@ -198,7 +150,8 @@ public Thread newThread(@NonNull final Runnable r) { private boolean noReconnect = false; - private Duration noReconnectAfter; + @NonNull + private Duration noReconnectAfter = Duration.ofDays(10); /** * Determines whether the socket will have {@link Socket#setKeepAlive(boolean)} set or not. @@ -269,7 +222,7 @@ public Engine(EngineListener listener, List hudsonUrls, String secretKey, S public Engine( EngineListener listener, - List hudsonUrls, + List urls, String secretKey, String agentName, String directConnection, @@ -278,8 +231,7 @@ public Engine( this.listener = listener; this.directConnection = directConnection; this.events.add(listener); - this.candidateUrls = - hudsonUrls.stream().map(Engine::ensureTrailingSlash).collect(Collectors.toList()); + this.candidateUrls = urls.stream().map(Engine::ensureTrailingSlash).collect(Collectors.toList()); this.secretKey = secretKey; this.agentName = agentName; this.instanceIdentity = instanceIdentity; @@ -430,7 +382,7 @@ public void setNoReconnect(boolean noReconnect) { this.noReconnect = noReconnect; } - public void setNoReconnectAfter(@CheckForNull Duration noReconnectAfter) { + public void setNoReconnectAfter(@NonNull Duration noReconnectAfter) { this.noReconnectAfter = noReconnectAfter; } @@ -535,277 +487,25 @@ public void removeListener(EngineListener el) { } @Override - @SuppressFBWarnings(value = "HARD_CODE_PASSWORD", justification = "Password doesn't need to be protected.") + @SuppressFBWarnings(value = "REC_CATCH_EXCEPTION", justification = "We need to catch all exceptions") public void run() { - if (webSocket) { - runWebSocket(); - return; - } - // Create the engine - try { - try (IOHub hub = IOHub.create(executor)) { - SSLContext context; - // prepare our SSLContext - try { - context = SSLContext.getInstance("TLS"); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("Java runtime specification requires support for TLS algorithm", e); - } - char[] password = "password".toCharArray(); - KeyStore store; - try { - store = KeyStore.getInstance(KeyStore.getDefaultType()); - } catch (KeyStoreException e) { - throw new IllegalStateException("Java runtime specification requires support for JKS key store", e); - } - try { - store.load(null, password); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("Java runtime specification requires support for JKS key store", e); - } catch (CertificateException e) { - throw new IllegalStateException("Empty keystore", e); - } - KeyManagerFactory kmf; - try { - kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException( - "Java runtime specification requires support for default key manager", e); - } - try { - kmf.init(store, password); - } catch (KeyStoreException | NoSuchAlgorithmException | UnrecoverableKeyException e) { - throw new IllegalStateException(e); - } - try { - context.init(kmf.getKeyManagers(), new TrustManager[] {agentTrustManager}, null); - } catch (KeyManagementException e) { - events.error(e); - return; - } - innerRun(hub, context, executor); - } - } catch (IOException e) { - events.error(e); - } - } - - @SuppressFBWarnings( - value = {"REC_CATCH_EXCEPTION"}, - justification = "checked exceptions were a mistake to begin with; connecting to Jenkins from agent") - private void runWebSocket() { - try { - String localCap = new Capability().toASCII(); - final Map> addedHeaders = new HashMap<>(); - addedHeaders.put(JnlpConnectionState.CLIENT_NAME_KEY, List.of(agentName)); - addedHeaders.put(JnlpConnectionState.SECRET_KEY, List.of(secretKey)); - addedHeaders.put(Capability.KEY, List.of(localCap)); - if (webSocketHeaders != null) { - for (Map.Entry entry : webSocketHeaders.entrySet()) { - addedHeaders.put(entry.getKey(), List.of(entry.getValue())); - } - } + try (var connector = getEndpointConnector()) { while (true) { - AtomicReference ch = new AtomicReference<>(); - class HeaderHandler extends ClientEndpointConfig.Configurator { - Capability remoteCapability = new Capability(); - - @Override - public void beforeRequest(Map> headers) { - headers.putAll(addedHeaders); - LOGGER.fine(() -> "Sending: " + headers); - } - - @Override - public void afterResponse(HandshakeResponse hr) { - LOGGER.fine(() -> "Receiving: " + hr.getHeaders()); - List remotingMinimumVersion = hr.getHeaders().get(REMOTING_MINIMUM_VERSION_HEADER); - if (remotingMinimumVersion != null && !remotingMinimumVersion.isEmpty()) { - VersionNumber minimumSupportedVersion = new VersionNumber(remotingMinimumVersion.get(0)); - VersionNumber currentVersion = new VersionNumber(Launcher.VERSION); - if (currentVersion.isOlderThan(minimumSupportedVersion)) { - events.error(new IOException( - "Agent version " + minimumSupportedVersion + " or newer is required.")); - } - } - try { - List cookies = hr.getHeaders().get(Engine.WEBSOCKET_COOKIE_HEADER); - if (cookies != null && !cookies.isEmpty()) { - addedHeaders.put(Engine.WEBSOCKET_COOKIE_HEADER, List.of(cookies.get(0))); - } else { - addedHeaders.remove(Engine.WEBSOCKET_COOKIE_HEADER); - } - List advertisedCapability = hr.getHeaders().get(Capability.KEY); - if (advertisedCapability == null) { - LOGGER.warning("Did not receive " + Capability.KEY + " header"); - } else { - remoteCapability = Capability.fromASCII(advertisedCapability.get(0)); - LOGGER.fine(() -> "received " + remoteCapability); - } - } catch (IOException x) { - events.error(x); - } - } - } - HeaderHandler headerHandler = new HeaderHandler(); - class AgentEndpoint extends Endpoint { - @SuppressFBWarnings( - value = "UWF_FIELD_NOT_INITIALIZED_IN_CONSTRUCTOR", - justification = "just trust me here") - AgentEndpoint.Transport transport; - - @Override - public void onOpen(Session session, EndpointConfig config) { - events.status("WebSocket connection open"); - session.addMessageHandler(ByteBuffer.class, this::onMessage); - try { - transport = new Transport(session); - ch.set(new ChannelBuilder(agentName, executor) - .withJarCacheOrDefault(jarCache) - . // unless EngineJnlpConnectionStateListener can be used for this purpose - build(transport)); - } catch (IOException x) { - events.error(x); - } - } - - private void onMessage(ByteBuffer message) { - try { - transport.receive(message); - } catch (IOException x) { - events.error(x); - } catch (InterruptedException x) { - events.error(x); - Thread.currentThread().interrupt(); - } - } - - @Override - @SuppressFBWarnings( - value = "RV_RETURN_VALUE_IGNORED_BAD_PRACTICE", - justification = - "We want the transport.terminate method to run asynchronously and don't want to wait for its status.") - public void onClose(Session session, CloseReason closeReason) { - LOGGER.fine(() -> "onClose: " + closeReason); - // making this call async to avoid potential deadlocks when some thread is holding a lock on the - // channel object while this thread is trying to acquire it to call Transport#terminate - ch.get().executor.submit(() -> transport.terminate(new ChannelClosedException(ch.get(), null))); - } - - @Override - @SuppressFBWarnings( - value = "RV_RETURN_VALUE_IGNORED_BAD_PRACTICE", - justification = - "We want the transport.terminate method to run asynchronously and don't want to wait for its status.") - public void onError(Session session, Throwable x) { - // TODO or would events.error(x) be better? - LOGGER.log(Level.FINE, null, x); - // as above - ch.get().executor.submit(() -> transport.terminate(new ChannelClosedException(ch.get(), x))); - } - - class Transport extends AbstractByteBufferCommandTransport { - final Session session; - - Transport(Session session) { - super(true); - this.session = session; - } - - @Override - protected void write(ByteBuffer headerAndData) throws IOException { - LOGGER.finest(() -> - "sending message of length " + (headerAndData.remaining() - ChunkHeader.SIZE)); - try { - session.getAsyncRemote() - .sendBinary(headerAndData) - .get(5, TimeUnit.MINUTES); - } catch (Exception x) { - throw new IOException(x); - } - } - - @Override - public Capability getRemoteCapability() { - return headerHandler.remoteCapability; - } - - @Override - public void closeWrite() throws IOException { - events.status("Write side closed"); - session.close(); - } - - @Override - public void closeRead() throws IOException { - events.status("Read side closed"); - session.close(); - } - } + if (connector.waitUntilReady() == null) { + break; } - hudsonUrl = candidateUrls.get(0); - String wsUrl = hudsonUrl.toString().replaceFirst("^http", "ws"); - WebSocketContainer container = ContainerProvider.getWebSocketContainer(); - if (container instanceof ClientManager) { - ClientManager client = (ClientManager) container; - - String proxyHost = System.getProperty("http.proxyHost", System.getenv("proxy_host")); - String proxyPort = System.getProperty("http.proxyPort"); - if (proxyHost != null - && "http".equals(hudsonUrl.getProtocol()) - && NoProxyEvaluator.shouldProxy(hudsonUrl.getHost())) { - URI proxyUri; - if (proxyPort != null) { - proxyUri = URI.create(String.format("http://%s:%s", proxyHost, proxyPort)); - } else { - proxyUri = URI.create(String.format("http://%s", proxyHost)); - } - client.getProperties().put(ClientProperties.PROXY_URI, proxyUri); - if (proxyCredentials != null) { - client.getProperties() - .put( - ClientProperties.PROXY_HEADERS, - Map.of( - "Proxy-Authorization", - "Basic " - + Base64.getEncoder() - .encodeToString(proxyCredentials.getBytes( - StandardCharsets.UTF_8)))); - } - } - - SSLContext sslContext = getSSLContext(candidateCertificates, disableHttpsCertValidation); - if (sslContext != null) { - SslEngineConfigurator sslEngineConfigurator = new SslEngineConfigurator(sslContext); - if (hostnameVerifier != null) { - sslEngineConfigurator.setHostnameVerifier(hostnameVerifier); - } - client.getProperties().put(ClientProperties.SSL_ENGINE_CONFIGURATOR, sslEngineConfigurator); - } + var channelFuture = connector.connect(); + if (channelFuture == null) { + break; } - if (!succeedsWithRetries(this::pingSuccessful)) { - return; - } - if (!succeedsWithRetries(() -> { - container.connectToServer( - new AgentEndpoint(), - ClientEndpointConfig.Builder.create() - .configurator(headerHandler) - .build(), - URI.create(wsUrl + "wsagents/")); - return true; - })) { - return; - } - while (ch.get() == null) { - Thread.sleep(100); - } - this.protocolName = "WebSocket"; + var channel = channelFuture.get(); + this.protocolName = connector.getProtocol(); + this.hudsonUrl = connector.getUrl(); events.status("Connected"); - ch.get().join(); + channel.join(); events.status("Terminated"); if (noReconnect) { - return; + break; } events.onDisconnect(); reconnect(); @@ -815,276 +515,7 @@ public void closeRead() throws IOException { } } - /** - * Evaluates a condition with exponential backoff until it succeeds or the timeout is reached. - * @param condition the condition to attempt to succeed with exponential backoff - * @return true if the condition succeeded, false if the condition failed and the timeout was reached - * @throws InterruptedException if the thread was interrupted while waiting. - */ - private boolean succeedsWithRetries(java.util.concurrent.Callable condition) throws InterruptedException { - var exponentialRetry = new ExponentialRetry(noReconnectAfter); - while (exponentialRetry != null) { - try { - if (condition.call()) { - return true; - } - } catch (Exception x) { - events.status("Failed to connect: " + x.getMessage()); - } - exponentialRetry = exponentialRetry.next(events); - } - return false; - } - - @SuppressFBWarnings( - value = {"URLCONNECTION_SSRF_FD"}, - justification = "url is provided by the user, and we are trying to connect to it") - private Boolean pingSuccessful() throws MalformedURLException { - // Unlike JnlpAgentEndpointResolver, we do not use $jenkins/tcpSlaveAgentListener/, as that will be - // a 404 if the TCP port is disabled. - URL ping = new URL(hudsonUrl, "login"); - try { - HttpURLConnection conn = (HttpURLConnection) ping.openConnection(); - int status = conn.getResponseCode(); - conn.disconnect(); - if (status == 200) { - return true; - } else { - events.status(ping + " is not ready: " + status); - } - } catch (IOException x) { - events.status(ping + " is not ready", x); - } - return false; - } - - private static class ExponentialRetry { - final int factor; - final Instant beginning; - final Duration delay; - final Duration timeout; - final Duration incrementDelay; - final Duration maxDelay; - - ExponentialRetry(Duration timeout) { - this(Duration.ofSeconds(0), timeout, 2, Duration.ofSeconds(1), Duration.ofSeconds(10)); - } - - ExponentialRetry( - Duration initialDelay, Duration timeout, int factor, Duration incrementDelay, Duration maxDelay) { - this.beginning = Instant.now(); - this.delay = initialDelay; - this.timeout = timeout; - this.factor = factor; - this.incrementDelay = incrementDelay; - this.maxDelay = maxDelay; - } - - ExponentialRetry(ExponentialRetry previous) { - beginning = previous.beginning; - factor = previous.factor; - timeout = previous.timeout; - incrementDelay = previous.incrementDelay; - maxDelay = previous.maxDelay; - delay = min(maxDelay, previous.delay.multipliedBy(previous.factor).plus(incrementDelay)); - } - - private static Duration min(Duration a, Duration b) { - return a.compareTo(b) < 0 ? a : b; - } - - boolean timeoutExceeded() { - return Util.shouldBailOut(beginning, timeout); - } - - ExponentialRetry next(EngineListenerSplitter events) throws InterruptedException { - var next = new ExponentialRetry(this); - if (next.timeoutExceeded()) { - events.status("Bailing out after " + DurationFormatter.format(next.timeout)); - return null; - } else { - events.status("Waiting " + DurationFormatter.format(next.delay) + " before retry"); - Thread.sleep(next.delay.toMillis()); - } - return next; - } - } - - private void reconnect() { - try { - events.status("Performing onReconnect operation."); - events.onReconnect(); - events.status("onReconnect operation completed."); - } catch (NoClassDefFoundError e) { - events.status("onReconnect operation failed."); - LOGGER.log(Level.WARNING, "Reconnection error.", e); - } - } - - private void innerRun(IOHub hub, SSLContext context, ExecutorService service) { - // Create the protocols that will be attempted to connect to the controller. - List> protocols = new JnlpProtocolHandlerFactory(service) - .withIOHub(hub) - .withSSLContext(context) - .withPreferNonBlockingIO(false) // we only have one connection, prefer blocking I/O - .handlers(); - final Map headers = new HashMap<>(); - headers.put(JnlpConnectionState.CLIENT_NAME_KEY, agentName); - headers.put(JnlpConnectionState.SECRET_KEY, secretKey); - List jenkinsUrls = new ArrayList<>(); - for (URL url : candidateUrls) { - jenkinsUrls.add(url.toExternalForm()); - } - JnlpEndpointResolver resolver = createEndpointResolver(jenkinsUrls, agentName); - - try { - boolean first = true; - var firstAttempt = Instant.now(); - while (true) { - if (first) { - first = false; - } else { - if (noReconnect) { - return; // exit - } - } - if (Util.shouldBailOut(firstAttempt, noReconnectAfter)) { - events.status("Bailing out after " + DurationFormatter.format(noReconnectAfter)); - return; - } - events.status("Locating server among " + candidateUrls); - final JnlpAgentEndpoint endpoint; - try { - endpoint = resolver.resolve(); - } catch (IOException e) { - if (!noReconnect) { - events.status( - "Could not locate server among " + candidateUrls + "; waiting 10 seconds before retry", - e); - // TODO refactor various sleep statements into a common method - TimeUnit.SECONDS.sleep(10); - continue; - } else { - if (Boolean.getBoolean( - Engine.class.getName() + ".nonFatalJnlpAgentEndpointResolutionExceptions")) { - events.status("Could not resolve JNLP agent endpoint", e); - } else { - events.error(e); - } - } - return; - } - if (endpoint == null) { - events.status("Could not resolve server among " + candidateUrls); - return; - } - hudsonUrl = endpoint.getServiceUrl(); - - events.status(String.format( - "Agent discovery successful%n" - + " Agent address: %s%n" - + " Agent port: %d%n" - + " Identity: %s", - endpoint.getHost(), endpoint.getPort(), KeyUtils.fingerprint(endpoint.getPublicKey()))); - PublicKeyMatchingX509ExtendedTrustManager delegate = new PublicKeyMatchingX509ExtendedTrustManager(); - RSAPublicKey publicKey = endpoint.getPublicKey(); - if (publicKey != null) { - // This is so that JNLP4-connect will only connect if the public key matches - // if the public key is not published then JNLP4-connect will refuse to connect - delegate.add(publicKey); - } - agentTrustManager.setDelegate(delegate); - - events.status("Handshaking"); - Socket jnlpSocket = connectTcp(endpoint); - Channel channel = null; - - try { - // Try available protocols. - boolean triedAtLeastOneProtocol = false; - for (JnlpProtocolHandler protocol : protocols) { - if (!protocol.isEnabled()) { - events.status("Protocol " + protocol.getName() + " is not enabled, skipping"); - continue; - } - if (jnlpSocket == null) { - jnlpSocket = connectTcp(endpoint); - } - if (!endpoint.isProtocolSupported(protocol.getName())) { - events.status("Server reports protocol " + protocol.getName() + " not supported, skipping"); - continue; - } - triedAtLeastOneProtocol = true; - events.status("Trying protocol: " + protocol.getName()); - try { - channel = protocol.connect( - jnlpSocket, - headers, - new EngineJnlpConnectionStateListener(endpoint.getPublicKey(), headers)) - .get(); - } catch (IOException ioe) { - events.status("Protocol " + protocol.getName() + " failed to establish channel", ioe); - } catch (RuntimeException e) { - events.status("Protocol " + protocol.getName() + " encountered a runtime error", e); - } catch (Error e) { - events.status( - "Protocol " + protocol.getName() + " could not be completed due to an error", e); - } catch (Throwable e) { - events.status("Protocol " + protocol.getName() + " encountered an unexpected exception", e); - } - - // On success do not try other protocols. - if (channel != null) { - this.protocolName = protocol.getName(); - break; - } - - // On failure form a new connection. - jnlpSocket.close(); - jnlpSocket = null; - } - - // If no protocol worked. - if (channel == null) { - if (triedAtLeastOneProtocol) { - onConnectionRejected("None of the protocols were accepted"); - } else { - onConnectionRejected("None of the protocols are enabled"); - return; // exit - } - continue; - } - - events.status("Connected"); - channel.join(); - events.status("Terminated"); - } finally { - if (jnlpSocket != null) { - try { - jnlpSocket.close(); - } catch (IOException e) { - events.status("Failed to close socket", e); - } - } - } - if (noReconnect) { - return; // exit - } - firstAttempt = Instant.now(); - events.onDisconnect(); - - // try to connect back to the server every 10 secs. - resolver.waitForReady(); - - reconnect(); - } - } catch (Throwable e) { - events.error(e); - } - } - - private JnlpEndpointResolver createEndpointResolver(List jenkinsUrls, String agentName) { - JnlpEndpointResolver resolver; + private JnlpEndpointResolver createJnlpEndpointResolver() { if (directConnection == null) { SSLSocketFactory sslSocketFactory = null; try { @@ -1092,53 +523,49 @@ private JnlpEndpointResolver createEndpointResolver(List jenkinsUrls, St } catch (Exception e) { events.error(e); } - resolver = new JnlpAgentEndpointResolver( - jenkinsUrls, + return new JnlpAgentEndpointResolver( + candidateUrls.stream().map(URL::toExternalForm).collect(Collectors.toList()), agentName, credentials, proxyCredentials, tunnel, sslSocketFactory, - disableHttpsCertValidation, - noReconnectAfter); + noReconnect, + noReconnectAfter, + events); } else { - resolver = - new JnlpAgentEndpointConfigurator(directConnection, instanceIdentity, protocols, proxyCredentials); + return new JnlpAgentEndpointConfigurator( + directConnection, instanceIdentity, protocols, proxyCredentials, events); } - return resolver; } - private void onConnectionRejected(String greeting) throws InterruptedException { - events.status( - "reconnect rejected, sleeping 10s: ", new Exception("The server rejected the connection: " + greeting)); - // TODO refactor various sleep statements into a common method - TimeUnit.SECONDS.sleep(10); + private EndpointConnector getEndpointConnector() { + var data = new EndpointConnectorData( + agentName, + secretKey, + executor, + events, + noReconnectAfter, + candidateCertificates, + disableHttpsCertValidation, + jarCache, + proxyCredentials); + if (webSocket) { + return new WebSocketConnector(data, candidateUrls.get(0), webSocketHeaders, hostnameVerifier); + } else { + var jnlpEndpointResolver = createJnlpEndpointResolver(); + return new InboundTCPConnector(data, candidateUrls, agentTrustManager, keepAlive, jnlpEndpointResolver); + } } - /** - * Connects to TCP agent host:port, with a few retries. - * @param endpoint Connection endpoint - * @throws IOException Connection failure or invalid parameter specification - */ - private Socket connectTcp(@NonNull JnlpAgentEndpoint endpoint) throws IOException, InterruptedException { - - String msg = "Connecting to " + endpoint.getHost() + ':' + endpoint.getPort(); - events.status(msg); - int retry = 1; - while (true) { - try { - final Socket s = - endpoint.open(SOCKET_TIMEOUT); // default is 30 mins. See PingThread for the ping interval - s.setKeepAlive(keepAlive); - return s; - } catch (IOException e) { - if (retry++ > 10) { - throw e; - } - // TODO refactor various sleep statements into a common method - TimeUnit.SECONDS.sleep(10); - events.status(msg + " (retrying:" + retry + ")", e); - } + private void reconnect() { + try { + events.status("Performing onReconnect operation."); + events.onReconnect(); + events.status("onReconnect operation completed."); + } catch (NoClassDefFoundError e) { + events.status("onReconnect operation failed."); + LOGGER.log(Level.WARNING, "Reconnection error.", e); } } @@ -1156,139 +583,13 @@ public static Engine current() { private static final Logger LOGGER = Logger.getLogger(Engine.class.getName()); - @SuppressFBWarnings(value = "PATH_TRAVERSAL_IN", justification = "File path is loaded from system properties.") - static KeyStore getCacertsKeyStore() - throws PrivilegedActionException, KeyStoreException, NoSuchProviderException, CertificateException, - NoSuchAlgorithmException, IOException { - Map properties = - AccessController.doPrivileged((PrivilegedExceptionAction>) () -> { - Map result = new HashMap<>(); - result.put("trustStore", System.getProperty("javax.net.ssl.trustStore")); - result.put("javaHome", System.getProperty("java.home")); - result.put( - "trustStoreType", - System.getProperty("javax.net.ssl.trustStoreType", KeyStore.getDefaultType())); - result.put("trustStoreProvider", System.getProperty("javax.net.ssl.trustStoreProvider", "")); - result.put("trustStorePasswd", System.getProperty("javax.net.ssl.trustStorePassword", "")); - return result; - }); - KeyStore keystore = null; - - FileInputStream trustStoreStream = null; - try { - String trustStore = properties.get("trustStore"); - if (!"NONE".equals(trustStore)) { - File trustStoreFile; - if (trustStore != null) { - trustStoreFile = new File(trustStore); - trustStoreStream = getFileInputStream(trustStoreFile); - } else { - String javaHome = properties.get("javaHome"); - trustStoreFile = new File(javaHome + File.separator + "lib" + File.separator + "security" - + File.separator + "jssecacerts"); - if ((trustStoreStream = getFileInputStream(trustStoreFile)) == null) { - trustStoreFile = new File(javaHome + File.separator + "lib" + File.separator + "security" - + File.separator + "cacerts"); - trustStoreStream = getFileInputStream(trustStoreFile); - } - } - - if (trustStoreStream != null) { - trustStore = trustStoreFile.getPath(); - } else { - trustStore = "No File Available, using empty keystore."; - } - } - - String trustStoreType = properties.get("trustStoreType"); - String trustStoreProvider = properties.get("trustStoreProvider"); - LOGGER.log(Level.FINE, "trustStore is: {0}", trustStore); - LOGGER.log(Level.FINE, "trustStore type is: {0}", trustStoreType); - LOGGER.log(Level.FINE, "trustStore provider is: {0}", trustStoreProvider); - - if (trustStoreType.length() != 0) { - LOGGER.log(Level.FINE, "init truststore"); - - if (trustStoreProvider.length() == 0) { - keystore = KeyStore.getInstance(trustStoreType); - } else { - keystore = KeyStore.getInstance(trustStoreType, trustStoreProvider); - } - - char[] trustStorePasswdChars = null; - String trustStorePasswd = properties.get("trustStorePasswd"); - if (trustStorePasswd.length() != 0) { - trustStorePasswdChars = trustStorePasswd.toCharArray(); - } - - keystore.load(trustStoreStream, trustStorePasswdChars); - if (trustStorePasswdChars != null) { - Arrays.fill(trustStorePasswdChars, (char) 0); - } - } - } finally { - if (trustStoreStream != null) { - trustStoreStream.close(); - } - } - - return keystore; - } - - @CheckForNull - private static FileInputStream getFileInputStream(final File file) throws PrivilegedActionException { - return AccessController.doPrivileged((PrivilegedExceptionAction) () -> { - try { - return file.exists() ? new FileInputStream(file) : null; - } catch (FileNotFoundException e) { - return null; - } - }); - } - - @CheckForNull - private static SSLContext getSSLContext(List x509Certificates, boolean noCertificateCheck) - throws PrivilegedActionException, KeyStoreException, NoSuchProviderException, CertificateException, - NoSuchAlgorithmException, IOException, KeyManagementException { - SSLContext sslContext = null; - if (noCertificateCheck) { - sslContext = SSLContext.getInstance("TLS"); - sslContext.init(null, new TrustManager[] {new NoCheckTrustManager()}, new SecureRandom()); - } else if (x509Certificates != null && !x509Certificates.isEmpty()) { - KeyStore keyStore = getCacertsKeyStore(); - // load the keystore - keyStore.load(null, null); - int i = 0; - for (X509Certificate c : x509Certificates) { - keyStore.setCertificateEntry(String.format("alias-%d", i++), c); - } - // prepare the trust manager - TrustManagerFactory trustManagerFactory = - TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - trustManagerFactory.init(keyStore); - // prepare the SSL context - SSLContext ctx = SSLContext.getInstance("TLS"); - // now we have our custom socket factory - ctx.init(null, trustManagerFactory.getTrustManagers(), null); - } - return sslContext; - } - - @CheckForNull - @Restricted(NoExternalUse.class) - static SSLSocketFactory getSSLSocketFactory(List x509Certificates, boolean noCertificateCheck) - throws PrivilegedActionException, KeyStoreException, NoSuchProviderException, CertificateException, - NoSuchAlgorithmException, IOException, KeyManagementException { - SSLContext sslContext = getSSLContext(x509Certificates, noCertificateCheck); - return sslContext != null ? sslContext.getSocketFactory() : null; - } - /** * Socket read timeout. * A {@link SocketInputStream#read()} call associated with underlying Socket will block for only this amount of time * @since 2.4 */ - static final int SOCKET_TIMEOUT = Integer.getInteger(Engine.class.getName() + ".socketTimeout", 30 * 60 * 1000); + public static final int SOCKET_TIMEOUT = + Integer.getInteger(Engine.class.getName() + ".socketTimeout", 30 * 60 * 1000); /** * Get the agent name associated with this Engine instance. @@ -1312,53 +613,4 @@ public String getAgentName() { public String getProtocolName() { return this.protocolName; } - - private class EngineJnlpConnectionStateListener extends JnlpConnectionStateListener { - - private final RSAPublicKey publicKey; - private final Map headers; - - public EngineJnlpConnectionStateListener(RSAPublicKey publicKey, Map headers) { - this.publicKey = publicKey; - this.headers = headers; - } - - @Override - public void beforeProperties(@NonNull JnlpConnectionState event) { - if (event instanceof Jnlp4ConnectionState) { - X509Certificate certificate = ((Jnlp4ConnectionState) event).getCertificate(); - if (certificate != null) { - String fingerprint = KeyUtils.fingerprint(certificate.getPublicKey()); - if (!KeyUtils.equals(publicKey, certificate.getPublicKey())) { - event.reject(new ConnectionRefusalException("Expecting identity " + fingerprint)); - } - events.status("Remote identity confirmed: " + fingerprint); - } - } - } - - @Override - public void afterProperties(@NonNull JnlpConnectionState event) { - event.approve(); - } - - @Override - public void beforeChannel(@NonNull JnlpConnectionState event) { - ChannelBuilder bldr = event.getChannelBuilder().withMode(Channel.Mode.BINARY); - if (jarCache != null) { - bldr.withJarCache(jarCache); - } - } - - @Override - public void afterChannel(@NonNull JnlpConnectionState event) { - // store the new cookie for next connection attempt - String cookie = event.getProperty(JnlpConnectionState.COOKIE_KEY); - if (cookie == null) { - headers.remove(JnlpConnectionState.COOKIE_KEY); - } else { - headers.put(JnlpConnectionState.COOKIE_KEY, cookie); - } - } - } } diff --git a/src/main/java/hudson/remoting/Launcher.java b/src/main/java/hudson/remoting/Launcher.java index 78161dbd7..7a0d1819a 100644 --- a/src/main/java/hudson/remoting/Launcher.java +++ b/src/main/java/hudson/remoting/Launcher.java @@ -78,6 +78,7 @@ import org.jenkinsci.remoting.engine.WorkDirManager; import org.jenkinsci.remoting.util.DurationFormatter; import org.jenkinsci.remoting.util.PathUtils; +import org.jenkinsci.remoting.util.SSLUtils; import org.jenkinsci.remoting.util.https.NoCheckHostnameVerifier; import org.kohsuke.args4j.Argument; import org.kohsuke.args4j.CmdLineException; @@ -517,7 +518,7 @@ private synchronized void initialize() throws IOException { // Initialize certificates createX509Certificates(); try { - sslSocketFactory = Engine.getSSLSocketFactory(x509Certificates, noCertificateCheck); + sslSocketFactory = SSLUtils.getSSLSocketFactory(x509Certificates, noCertificateCheck); } catch (GeneralSecurityException | PrivilegedActionException e) { throw new RuntimeException(e); } @@ -1121,7 +1122,9 @@ private Engine createEngine() throws IOException { engine.setJarCache(new FileSystemJarCache(jarCache, true)); } engine.setNoReconnect(noReconnect); - engine.setNoReconnectAfter(noReconnectAfter); + if (noReconnectAfter != null) { + engine.setNoReconnectAfter(noReconnectAfter); + } engine.setKeepAlive(!noKeepAlive); if (noCertificateCheck) { diff --git a/src/main/java/org/jenkinsci/remoting/engine/EndpointConnector.java b/src/main/java/org/jenkinsci/remoting/engine/EndpointConnector.java new file mode 100644 index 000000000..d438fffb7 --- /dev/null +++ b/src/main/java/org/jenkinsci/remoting/engine/EndpointConnector.java @@ -0,0 +1,48 @@ +package org.jenkinsci.remoting.engine; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.Nullable; +import hudson.remoting.Channel; +import java.io.Closeable; +import java.net.URL; +import java.util.concurrent.Future; + +/** + * Represents a connection to a remote endpoint to open a {@link Channel}. + *

+ * First, {@link hudson.remoting.Engine} creates an instance of {@link EndpointConnector} and calls {@link #waitUntilReady()}. Once it has returned {@code true}, it calls {@link #connect()} to establish the connection. + *

+ * Then {@link #getProtocol()} and {@link #getUrl()} are called to get the protocol and URL of the endpoint. + *

+ * Upon termination, @{link #close()} is called. + * + * @since TODO + */ +public interface EndpointConnector extends Closeable { + + /** + * @return a future to the channel to be established. Returns null if the connection cannot be established at all. + * @throws Exception + */ + @CheckForNull + Future connect() throws Exception; + + /** + * Waits until the connection can be established. + * @return true if the connection is ready, null if the connection never got ready + * @throws InterruptedException if the thread is interrupted + */ + @CheckForNull + Boolean waitUntilReady() throws InterruptedException; + + /** + * @return The name of the protocol used by this connection. + */ + String getProtocol(); + + /** + * @return the URL of the endpoint, if {@link #waitUntilReady()} returned {@code true}. + */ + @Nullable + URL getUrl(); +} diff --git a/src/main/java/org/jenkinsci/remoting/engine/EndpointConnectorData.java b/src/main/java/org/jenkinsci/remoting/engine/EndpointConnectorData.java new file mode 100644 index 000000000..ffd5cfae8 --- /dev/null +++ b/src/main/java/org/jenkinsci/remoting/engine/EndpointConnectorData.java @@ -0,0 +1,32 @@ +package org.jenkinsci.remoting.engine; + +import hudson.remoting.EngineListenerSplitter; +import hudson.remoting.JarCache; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ExecutorService; + +/** + * Captures the data needed to connect to any endpoint. + * @param agentName the agent name + * @param secretKey the secret key + * @param executor the thread pool to use for handling TCP connections + * @param events the listener to log to + * @param noReconnectAfter Specifies the duration after which the connection should not be re-established. + * @param candidateCertificates the list of certificates to be used for the connection + * @param disableHttpsCertValidation whether to disable HTTPS certificate validation + * @param jarCache Where to store the jar cache + * @param proxyCredentials Credentials to use for proxy authentication, if any. + * @since TODO + */ +public record EndpointConnectorData( + String agentName, + String secretKey, + ExecutorService executor, + EngineListenerSplitter events, + Duration noReconnectAfter, + List candidateCertificates, + boolean disableHttpsCertValidation, + JarCache jarCache, + String proxyCredentials) {} diff --git a/src/main/java/org/jenkinsci/remoting/engine/InboundTCPConnector.java b/src/main/java/org/jenkinsci/remoting/engine/InboundTCPConnector.java new file mode 100644 index 000000000..20f1a960d --- /dev/null +++ b/src/main/java/org/jenkinsci/remoting/engine/InboundTCPConnector.java @@ -0,0 +1,254 @@ +package org.jenkinsci.remoting.engine; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.remoting.Channel; +import hudson.remoting.ChannelBuilder; +import hudson.remoting.Engine; +import java.io.Closeable; +import java.io.IOException; +import java.net.Socket; +import java.net.URL; +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAPublicKey; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Future; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import org.jenkinsci.remoting.protocol.IOHub; +import org.jenkinsci.remoting.protocol.cert.DelegatingX509ExtendedTrustManager; +import org.jenkinsci.remoting.protocol.cert.PublicKeyMatchingX509ExtendedTrustManager; +import org.jenkinsci.remoting.protocol.impl.ConnectionRefusalException; +import org.jenkinsci.remoting.util.KeyUtils; +import org.jenkinsci.remoting.util.SSLUtils; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Connects to a controller using inbound TCP. + */ +@Restricted(NoExternalUse.class) +public class InboundTCPConnector implements EndpointConnector { + private static final Logger LOGGER = Logger.getLogger(InboundTCPConnector.class.getName()); + + private final JnlpEndpointResolver jnlpEndpointResolver; + private final List candidateUrls; + private final DelegatingX509ExtendedTrustManager agentTrustManager; + private final boolean keepAlive; + private final EndpointConnectorData data; + + private URL url; + /** + * Name of the protocol that was used to successfully connect to the controller. + */ + private String protocolName; + + /** + * Tracks {@link Closeable} resources that need to be closed when this connector is closed. + */ + @NonNull + private final List closeables = new ArrayList<>(); + + @Override + public URL getUrl() { + return url; + } + + public InboundTCPConnector( + EndpointConnectorData data, + @NonNull List candidateUrls, + @CheckForNull DelegatingX509ExtendedTrustManager agentTrustManager, + boolean keepAlive, + @NonNull JnlpEndpointResolver jnlpEndpointResolver) { + this.data = data; + this.candidateUrls = new ArrayList<>(candidateUrls); + this.agentTrustManager = agentTrustManager; + this.keepAlive = keepAlive; + this.jnlpEndpointResolver = jnlpEndpointResolver; + } + + private class EngineJnlpConnectionStateListener extends JnlpConnectionStateListener { + + private final RSAPublicKey publicKey; + private final Map headers; + + public EngineJnlpConnectionStateListener(RSAPublicKey publicKey, Map headers) { + this.publicKey = publicKey; + this.headers = headers; + } + + @Override + public void beforeProperties(@NonNull JnlpConnectionState event) { + if (event instanceof Jnlp4ConnectionState) { + X509Certificate certificate = ((Jnlp4ConnectionState) event).getCertificate(); + if (certificate != null) { + String fingerprint = KeyUtils.fingerprint(certificate.getPublicKey()); + if (!KeyUtils.equals(publicKey, certificate.getPublicKey())) { + event.reject(new ConnectionRefusalException("Expecting identity " + fingerprint)); + } + data.events().status("Remote identity confirmed: " + fingerprint); + } + } + } + + @Override + public void afterProperties(@NonNull JnlpConnectionState event) { + event.approve(); + } + + @Override + public void beforeChannel(@NonNull JnlpConnectionState event) { + ChannelBuilder bldr = event.getChannelBuilder().withMode(Channel.Mode.BINARY); + if (data.jarCache() != null) { + bldr.withJarCache(data.jarCache()); + } + } + + @Override + public void afterChannel(@NonNull JnlpConnectionState event) { + // store the new cookie for next connection attempt + String cookie = event.getProperty(JnlpConnectionState.COOKIE_KEY); + if (cookie == null) { + headers.remove(JnlpConnectionState.COOKIE_KEY); + } else { + headers.put(JnlpConnectionState.COOKIE_KEY, cookie); + } + } + } + + @Override + public Future connect() throws Exception { + var hub = IOHub.create(data.executor()); + closeables.add(hub); + var context = SSLUtils.createSSLContext(agentTrustManager); + + final JnlpAgentEndpoint endpoint = RetryUtils.succeedsWithRetries( + jnlpEndpointResolver::resolve, + data.noReconnectAfter(), + data.events(), + x -> "Could not locate server among " + candidateUrls + ": " + x.getMessage()); + if (endpoint == null) { + data.events().status("Could not resolve server among " + this.candidateUrls); + return null; + } + url = endpoint.getServiceUrl(); + + data.events() + .status(String.format( + "Agent discovery successful%n" + + " Agent address: %s%n" + + " Agent port: %d%n" + + " Identity: %s", + endpoint.getHost(), endpoint.getPort(), KeyUtils.fingerprint(endpoint.getPublicKey()))); + PublicKeyMatchingX509ExtendedTrustManager delegate = new PublicKeyMatchingX509ExtendedTrustManager(); + RSAPublicKey publicKey = endpoint.getPublicKey(); + if (publicKey != null) { + // This is so that JNLP4-connect will only connect if the public key matches + // if the public key is not published then JNLP4-connect will refuse to connect + delegate.add(publicKey); + } + this.agentTrustManager.setDelegate(delegate); + + data.events().status("Handshaking"); + // must be read-write + final Map headers = new HashMap<>(); + headers.put(JnlpConnectionState.CLIENT_NAME_KEY, data.agentName()); + headers.put(JnlpConnectionState.SECRET_KEY, data.secretKey()); + // Create the protocols that will be attempted to connect to the controller. + var clientProtocols = new JnlpProtocolHandlerFactory(data.executor()) + .withIOHub(hub) + .withSSLContext(context) + .withPreferNonBlockingIO(false) // we only have one connection, prefer blocking I/O + .handlers(); + var negotiatedProtocols = clientProtocols.stream() + .filter(JnlpProtocolHandler::isEnabled) + .filter(p -> endpoint.isProtocolSupported(p.getName())) + .collect(Collectors.toSet()); + var serverProtocols = endpoint.getProtocols() == null ? "?" : String.join(",", endpoint.getProtocols()); + LOGGER.info(buildDebugProtocolsMessage(serverProtocols, clientProtocols, negotiatedProtocols)); + for (var protocol : negotiatedProtocols) { + var jnlpSocket = RetryUtils.succeedsWithRetries( + () -> { + data.events().status("Connecting to " + endpoint.describe() + " using " + protocol.getName()); + // default is 30 mins. See PingThread for the ping interval + final Socket s = endpoint.open(Engine.SOCKET_TIMEOUT); + s.setKeepAlive(keepAlive); + return s; + }, + data.noReconnectAfter(), + data.events()); + if (jnlpSocket == null) { + return null; + } + closeables.add(jnlpSocket); + try { + protocolName = protocol.getName(); + return protocol.connect( + jnlpSocket, headers, new EngineJnlpConnectionStateListener(endpoint.getPublicKey(), headers)); + } catch (IOException ioe) { + data.events().status("Protocol " + protocol.getName() + " failed to establish channel", ioe); + protocolName = null; + } catch (RuntimeException e) { + data.events().status("Protocol " + protocol.getName() + " encountered a runtime error", e); + protocolName = null; + } + // On failure form a new connection. + jnlpSocket.close(); + closeables.remove(jnlpSocket); + } + if (negotiatedProtocols.isEmpty()) { + data.events() + .status( + "reconnect rejected", + new Exception("The server rejected the connection: None of the protocols were accepted")); + } else { + data.events() + .status( + "reconnect rejected", + new Exception("The server rejected the connection: None of the protocols are enabled")); + } + return null; + } + + @NonNull + private static String buildDebugProtocolsMessage( + String serverProtocols, + List> clientProtocols, + Set> negotiatedProtocols) { + return "Protocols support: Server " + "[" + serverProtocols + "]" + + ", Client " + "[" + + clientProtocols.stream() + .map(p -> p.getName() + (!p.isEnabled() ? " (disabled)" : "")) + .collect(Collectors.joining(",")) + + "]" + + ", Negociated: " + "[" + + negotiatedProtocols.stream().map(JnlpProtocolHandler::getName).collect(Collectors.joining(",")) + + "]"; + } + + @Override + public Boolean waitUntilReady() throws InterruptedException { + jnlpEndpointResolver.waitForReady(); + return true; + } + + @Override + public String getProtocol() { + return protocolName; + } + + @Override + public void close() { + closeables.forEach(c -> { + try { + c.close(); + } catch (IOException e) { + data.events().status("Failed to close resource " + c, e); + } + }); + } +} diff --git a/src/main/java/org/jenkinsci/remoting/engine/JnlpAgentEndpoint.java b/src/main/java/org/jenkinsci/remoting/engine/JnlpAgentEndpoint.java index 58001338c..cf3a13392 100644 --- a/src/main/java/org/jenkinsci/remoting/engine/JnlpAgentEndpoint.java +++ b/src/main/java/org/jenkinsci/remoting/engine/JnlpAgentEndpoint.java @@ -133,6 +133,10 @@ public JnlpAgentEndpoint( this.proxyCredentials = proxyCredentials; } + String describe() { + return getHost() + ':' + getPort(); + } + /** * Gets the socket address. * diff --git a/src/main/java/org/jenkinsci/remoting/engine/JnlpAgentEndpointConfigurator.java b/src/main/java/org/jenkinsci/remoting/engine/JnlpAgentEndpointConfigurator.java index 9055a843b..a331f37dc 100644 --- a/src/main/java/org/jenkinsci/remoting/engine/JnlpAgentEndpointConfigurator.java +++ b/src/main/java/org/jenkinsci/remoting/engine/JnlpAgentEndpointConfigurator.java @@ -23,11 +23,11 @@ */ package org.jenkinsci.remoting.engine; +import hudson.remoting.EngineListenerSplitter; import java.io.IOException; import java.security.interfaces.RSAPublicKey; import java.security.spec.InvalidKeySpecException; import java.util.Set; -import java.util.logging.Level; import java.util.logging.Logger; public class JnlpAgentEndpointConfigurator extends JnlpEndpointResolver { @@ -38,17 +38,24 @@ public class JnlpAgentEndpointConfigurator extends JnlpEndpointResolver { private final Set protocols; private final String directionConnection; private final String proxyCredentials; + private final EngineListenerSplitter events; public JnlpAgentEndpointConfigurator( - String directConnection, String instanceIdentity, Set protocols, String proxyCredentials) { + String directConnection, + String instanceIdentity, + Set protocols, + String proxyCredentials, + EngineListenerSplitter events) { this.directionConnection = directConnection; this.instanceIdentity = instanceIdentity; this.protocols = protocols; this.proxyCredentials = proxyCredentials; + this.events = events; } @Override public JnlpAgentEndpoint resolve() throws IOException { + events.status("Using direct connection to " + directionConnection); RSAPublicKey identity; try { identity = getIdentity(instanceIdentity); @@ -65,7 +72,5 @@ public JnlpAgentEndpoint resolve() throws IOException { } @Override - public void waitForReady() { - LOGGER.log(Level.INFO, "Sleeping 10s before reconnect."); - } + public void waitForReady() {} } diff --git a/src/main/java/org/jenkinsci/remoting/engine/JnlpAgentEndpointResolver.java b/src/main/java/org/jenkinsci/remoting/engine/JnlpAgentEndpointResolver.java index 6d6b7e936..9edded544 100644 --- a/src/main/java/org/jenkinsci/remoting/engine/JnlpAgentEndpointResolver.java +++ b/src/main/java/org/jenkinsci/remoting/engine/JnlpAgentEndpointResolver.java @@ -27,22 +27,19 @@ import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import hudson.remoting.Engine; +import hudson.remoting.EngineListenerSplitter; import hudson.remoting.Launcher; import hudson.remoting.NoProxyEvaluator; -import hudson.remoting.Util; import java.io.IOException; import java.net.Authenticator; -import java.net.ConnectException; import java.net.HttpURLConnection; import java.net.InetSocketAddress; import java.net.MalformedURLException; -import java.net.NoRouteToHostException; import java.net.PasswordAuthentication; import java.net.Proxy; import java.net.ProxySelector; import java.net.Socket; import java.net.SocketAddress; -import java.net.SocketTimeoutException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; @@ -51,7 +48,6 @@ import java.security.interfaces.RSAPublicKey; import java.security.spec.InvalidKeySpecException; import java.time.Duration; -import java.time.Instant; import java.util.ArrayList; import java.util.Base64; import java.util.Iterator; @@ -69,10 +65,8 @@ import javax.net.ssl.HostnameVerifier; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLSocketFactory; -import org.jenkinsci.remoting.util.DurationFormatter; import org.jenkinsci.remoting.util.ThrowableUtils; import org.jenkinsci.remoting.util.VersionNumber; -import org.jenkinsci.remoting.util.https.NoCheckHostnameVerifier; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; @@ -97,12 +91,15 @@ public class JnlpAgentEndpointResolver extends JnlpEndpointResolver { private SSLSocketFactory sslSocketFactory; - private boolean disableHttpsCertValidation; - - private HostnameVerifier hostnameVerifier; + private boolean noReconnect; + @NonNull private Duration noReconnectAfter; + private EngineListenerSplitter events; + + private boolean first = true; + /** * If specified, only the protocols from the list will be tried during the connection. * The option provides protocol names, but the order of the check is defined internally and cannot be changed. @@ -119,85 +116,24 @@ public JnlpAgentEndpointResolver( String proxyCredentials, String tunnel, SSLSocketFactory sslSocketFactory, - boolean disableHttpsCertValidation, - Duration noReconnectAfter) { + boolean noReconnect, + @NonNull Duration noReconnectAfter, + EngineListenerSplitter events) { this.jenkinsUrls = new ArrayList<>(jenkinsUrls); this.agentName = agentName; this.credentials = credentials; this.proxyCredentials = proxyCredentials; this.tunnel = tunnel; this.sslSocketFactory = sslSocketFactory; - setDisableHttpsCertValidation(disableHttpsCertValidation); + this.noReconnect = noReconnect; this.noReconnectAfter = noReconnectAfter; - } - - public SSLSocketFactory getSslSocketFactory() { - return sslSocketFactory; - } - - public void setSslSocketFactory(SSLSocketFactory sslSocketFactory) { - this.sslSocketFactory = sslSocketFactory; - } - - public String getCredentials() { - return credentials; - } - - public void setCredentials(String credentials) { - this.credentials = credentials; - } - - public void setCredentials(String user, String pass) { - this.credentials = user + ":" + pass; - } - - public String getProxyCredentials() { - return proxyCredentials; - } - - public void setProxyCredentials(String proxyCredentials) { - this.proxyCredentials = proxyCredentials; - } - - public void setProxyCredentials(String user, String pass) { - this.proxyCredentials = user + ":" + pass; - } - - @CheckForNull - public String getTunnel() { - return tunnel; - } - - public void setTunnel(@CheckForNull String tunnel) { - this.tunnel = tunnel; - } - - /** - * Determine if certificate checking should be ignored for JNLP endpoint - * - * @return {@code true} if the HTTPs certificate is disabled, endpoint check is ignored - */ - public boolean isDisableHttpsCertValidation() { - return disableHttpsCertValidation; - } - - /** - * Sets if the HTTPs certificate check should be disabled. - * - * This behavior is not recommended. - */ - public void setDisableHttpsCertValidation(boolean disableHttpsCertValidation) { - this.disableHttpsCertValidation = disableHttpsCertValidation; - if (disableHttpsCertValidation) { - this.hostnameVerifier = new NoCheckHostnameVerifier(); - } else { - this.hostnameVerifier = null; - } + this.events = events; } @CheckForNull @Override public JnlpAgentEndpoint resolve() throws IOException { + events.status("Locating server among " + this.jenkinsUrls); IOException firstError = null; for (String jenkinsUrl : jenkinsUrls) { if (jenkinsUrl == null) { @@ -218,8 +154,8 @@ public JnlpAgentEndpoint resolve() throws IOException { } // find out the TCP port - HttpURLConnection con = (HttpURLConnection) openURLConnection( - salURL, agentName, credentials, proxyCredentials, sslSocketFactory, hostnameVerifier); + HttpURLConnection con = (HttpURLConnection) + openURLConnection(salURL, agentName, credentials, proxyCredentials, sslSocketFactory, null); try { try { con.setConnectTimeout(30000); @@ -275,7 +211,7 @@ public JnlpAgentEndpoint resolve() throws IOException { + "to define the supported protocols."); } else { LOGGER.log( - Level.INFO, "Remoting server accepts the following protocols: {0}", agentProtocolNames); + Level.FINE, "Remoting server accepts the following protocols: " + agentProtocolNames); } } @@ -293,6 +229,11 @@ public JnlpAgentEndpoint resolve() throws IOException { } String idHeader = con.getHeaderField("X-Instance-Identity"); + if (idHeader == null) { + firstError = ThrowableUtils.chain( + firstError, new IOException(jenkinsUrl + " is missing instance-identity plugin")); + continue; + } RSAPublicKey identity; try { identity = getIdentity(idHeader); @@ -433,55 +374,15 @@ private URL toAgentListenerURL(@NonNull String jenkinsUrl) throws MalformedURLEx @Override public void waitForReady() throws InterruptedException { - Thread t = Thread.currentThread(); - String oldName = t.getName(); - try { - int retries = 0; - Instant firstAttempt = Instant.now(); - while (true) { - // TODO refactor various sleep statements into a common method - if (Util.shouldBailOut(firstAttempt, noReconnectAfter)) { - LOGGER.info("Bailing out after " + DurationFormatter.format(noReconnectAfter)); - return; - } - Thread.sleep(1000 * 10); - // Jenkins top page might be read-protected. see http://www.nabble - // .com/more-lenient-retry-logic-in-Engine.waitForServerToBack-td24703172.html - if (jenkinsUrls.isEmpty()) { - // returning here will cause the whole loop to be broken and all the urls to be tried again - return; - } - String firstUrl = jenkinsUrls.get(0); - try { - URL url = toAgentListenerURL(firstUrl); - - retries++; - t.setName(oldName + ": trying " + url + " for " + retries + " times"); - - HttpURLConnection con = (HttpURLConnection) openURLConnection( - url, agentName, credentials, proxyCredentials, sslSocketFactory, hostnameVerifier); - con.setConnectTimeout(5000); - con.setReadTimeout(5000); - con.connect(); - if (con.getResponseCode() == 200) { - return; - } - LOGGER.log( - Level.INFO, - "Controller isn''t ready to talk to us on {0}. Will try again: response code={1}", - new Object[] {url, con.getResponseCode()}); - } catch (SocketTimeoutException | ConnectException | NoRouteToHostException e) { - LOGGER.log(Level.INFO, "Failed to connect to {0}. Will try again: {1} {2}", new String[] { - firstUrl, e.getClass().getName(), e.getMessage() - }); - } catch (IOException e) { - // report the failure - LOGGER.log(Level.INFO, e, () -> "Failed to connect to " + firstUrl + ". Will try again"); - } - } - } finally { - t.setName(oldName); + if (RetryUtils.succeedsWithRetries( + this::ping, + first && noReconnect ? Duration.ZERO : noReconnectAfter, + events, + x -> "Could not locate server among " + jenkinsUrls + ": " + x.getMessage()) + == null) { + throw new RuntimeException("Could not locate server among " + jenkinsUrls); } + first = false; } @CheckForNull @@ -637,4 +538,26 @@ public static URLConnection openURLConnection( } return con; } + + @SuppressFBWarnings(value = "NP_BOOLEAN_RETURN_NULL", justification = "null is used to indicate no connection") + private Boolean ping() throws IOException { + for (String jenkinsUrl : jenkinsUrls) { + URL url = toAgentListenerURL(jenkinsUrl); + HttpURLConnection con = (HttpURLConnection) + openURLConnection(url, agentName, credentials, proxyCredentials, sslSocketFactory, null); + con.setConnectTimeout(5000); + con.setReadTimeout(5000); + con.connect(); + if (con.getResponseCode() == 200) { + return true; + } else if (con.getResponseCode() == 404) { + events.status("Controller isn't ready to talk to us on " + url + + ". Maybe TCP port for inbound agents is disabled?"); + } else { + events.status("Controller isn't ready to talk to us on " + url + ". Will try again: response code=" + + con.getResponseCode()); + } + } + return null; + } } diff --git a/src/main/java/org/jenkinsci/remoting/engine/RetryUtils.java b/src/main/java/org/jenkinsci/remoting/engine/RetryUtils.java new file mode 100644 index 000000000..01854f55c --- /dev/null +++ b/src/main/java/org/jenkinsci/remoting/engine/RetryUtils.java @@ -0,0 +1,90 @@ +package org.jenkinsci.remoting.engine; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import hudson.remoting.EngineListenerSplitter; +import hudson.remoting.Util; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.Callable; +import java.util.function.Function; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.jenkinsci.remoting.util.DurationFormatter; + +class RetryUtils { + private static final Logger LOGGER = Logger.getLogger(RetryUtils.class.getName()); + + @CheckForNull + static T succeedsWithRetries( + @NonNull Callable supplier, @NonNull Duration noReconnectAfter, @NonNull EngineListenerSplitter events) + throws InterruptedException { + return succeedsWithRetries(supplier, noReconnectAfter, events, x -> "Failed to connect: " + x.getMessage()); + } + /** + * Evaluates a supplier with exponential backoff until it provides a non-null value or the timeout is reached. + * @param supplier supplies an object. If null, retries with exponential backoff will be attempted. + * @return true if the condition succeeded, false if the condition failed and the timeout was reached + * @throws InterruptedException if the thread was interrupted while waiting. + */ + @CheckForNull + static T succeedsWithRetries( + @NonNull Callable supplier, + @NonNull Duration noReconnectAfter, + @NonNull EngineListenerSplitter events, + @NonNull Function exceptionConsumer) + throws InterruptedException { + for (var exponentialRetry = new ExponentialRetry(noReconnectAfter); + exponentialRetry != null; + exponentialRetry = exponentialRetry.next(events)) { + try { + var result = supplier.call(); + if (result != null) { + return result; + } + } catch (Exception x) { + var msg = exceptionConsumer.apply(x); + events.status(msg); + LOGGER.log(Level.FINE, msg, x); + } + } + return null; + } + + private record ExponentialRetry( + int factor, + Instant beginning, + Duration delay, + Duration timeout, + Duration incrementDelay, + Duration maxDelay) { + + ExponentialRetry(Duration timeout) { + this(2, Instant.now(), Duration.ofSeconds(0), timeout, Duration.ofSeconds(1), Duration.ofSeconds(10)); + } + + private static Duration min(Duration a, Duration b) { + return a.compareTo(b) < 0 ? a : b; + } + + boolean timeoutExceeded() { + return Util.shouldBailOut(beginning, timeout); + } + + ExponentialRetry next(EngineListenerSplitter events) throws InterruptedException { + var next = new ExponentialRetry(factor, beginning, nextDelay(), timeout, incrementDelay, maxDelay); + if (next.timeoutExceeded()) { + events.status("Bailing out after " + DurationFormatter.format(next.timeout)); + return null; + } else { + events.status("Waiting " + DurationFormatter.format(next.delay) + " before retry"); + Thread.sleep(next.delay.toMillis()); + } + return next; + } + + private Duration nextDelay() { + return min(maxDelay, delay.multipliedBy(factor).plus(incrementDelay)); + } + } +} diff --git a/src/main/java/org/jenkinsci/remoting/engine/WebSocketConnector.java b/src/main/java/org/jenkinsci/remoting/engine/WebSocketConnector.java new file mode 100644 index 000000000..c5dd0e32b --- /dev/null +++ b/src/main/java/org/jenkinsci/remoting/engine/WebSocketConnector.java @@ -0,0 +1,368 @@ +package org.jenkinsci.remoting.engine; + +import static org.jenkinsci.remoting.util.SSLUtils.getSSLContext; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.NonNull; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import hudson.remoting.AbstractByteBufferCommandTransport; +import hudson.remoting.Capability; +import hudson.remoting.Channel; +import hudson.remoting.ChannelBuilder; +import hudson.remoting.ChannelClosedException; +import hudson.remoting.ChunkHeader; +import hudson.remoting.Engine; +import hudson.remoting.EngineListenerSplitter; +import hudson.remoting.JarCache; +import hudson.remoting.Launcher; +import hudson.remoting.NoProxyEvaluator; +import jakarta.websocket.ClientEndpointConfig; +import jakarta.websocket.CloseReason; +import jakarta.websocket.ContainerProvider; +import jakarta.websocket.Endpoint; +import jakarta.websocket.EndpointConfig; +import jakarta.websocket.HandshakeResponse; +import jakarta.websocket.Session; +import jakarta.websocket.WebSocketContainer; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; +import org.glassfish.tyrus.client.ClientManager; +import org.glassfish.tyrus.client.ClientProperties; +import org.glassfish.tyrus.client.SslEngineConfigurator; +import org.jenkinsci.remoting.util.VersionNumber; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Connects to a controller using WebSockets. + */ +@Restricted(NoExternalUse.class) +public class WebSocketConnector implements EndpointConnector { + private static final Logger LOGGER = Logger.getLogger(WebSocketConnector.class.getName()); + private final EndpointConnectorData data; + + @Override + @NonNull + public URL getUrl() { + return url; + } + + /** + * The URL to connect to. + */ + @NonNull + private final URL url; + + /** + * Headers to be added to the initial request for connection. + */ + @NonNull + private final Map headers; + + /** + * A custom hostname verifier to use for HTTPS connections. + */ + @CheckForNull + private final HostnameVerifier hostnameVerifier; + + public WebSocketConnector( + EndpointConnectorData data, + @NonNull URL url, + @CheckForNull Map headers, + @CheckForNull HostnameVerifier hostnameVerifier) { + this.data = data; + this.url = url; + this.headers = headers == null ? Map.of() : new HashMap<>(headers); + this.hostnameVerifier = hostnameVerifier; + } + + @SuppressFBWarnings( + value = {"URLCONNECTION_SSRF_FD", "NP_BOOLEAN_RETURN_NULL"}, + justification = "url is provided by the user, and we are trying to connect to it") + private Boolean pingSuccessful() throws MalformedURLException { + // Unlike JnlpAgentEndpointResolver, we do not use $jenkins/tcpSlaveAgentListener/, as that will be + // a 404 if the TCP port is disabled. + URL ping = new URL(url, "login"); + try { + HttpURLConnection conn = (HttpURLConnection) ping.openConnection(); + int status = conn.getResponseCode(); + conn.disconnect(); + if (status == 200) { + return Boolean.TRUE; + } else { + data.events().status(ping + " is not ready: " + status); + } + } catch (IOException x) { + data.events().status(ping + " is not ready: " + x.getMessage()); + } + return null; + } + + @Override + public void close() throws IOException { + // no-op + } + + private static class HeaderHandler extends ClientEndpointConfig.Configurator { + private final Map> addedHeaders; + private final EngineListenerSplitter events; + private Capability remoteCapability; + + HeaderHandler(Map> addedHeaders, EngineListenerSplitter events) { + this.addedHeaders = new HashMap<>(addedHeaders); + this.events = events; + this.remoteCapability = new Capability(); + } + + @Override + public void beforeRequest(Map> headers) { + headers.putAll(addedHeaders); + LOGGER.fine(() -> "Sending: " + headers); + } + + @Override + public void afterResponse(HandshakeResponse hr) { + LOGGER.fine(() -> "Receiving: " + hr.getHeaders()); + List remotingMinimumVersion = hr.getHeaders().get(Engine.REMOTING_MINIMUM_VERSION_HEADER); + if (remotingMinimumVersion != null && !remotingMinimumVersion.isEmpty()) { + VersionNumber minimumSupportedVersion = new VersionNumber(remotingMinimumVersion.get(0)); + VersionNumber currentVersion = new VersionNumber(Launcher.VERSION); + if (currentVersion.isOlderThan(minimumSupportedVersion)) { + events.error( + new IOException("Agent version " + minimumSupportedVersion + " or newer is required.")); + } + } + try { + List cookies = hr.getHeaders().get(Engine.WEBSOCKET_COOKIE_HEADER); + if (cookies != null && !cookies.isEmpty()) { + addedHeaders.put(Engine.WEBSOCKET_COOKIE_HEADER, List.of(cookies.get(0))); + } else { + addedHeaders.remove(Engine.WEBSOCKET_COOKIE_HEADER); + } + List advertisedCapability = hr.getHeaders().get(Capability.KEY); + if (advertisedCapability == null) { + LOGGER.warning("Did not receive " + Capability.KEY + " header"); + } else { + remoteCapability = Capability.fromASCII(advertisedCapability.get(0)); + LOGGER.fine(() -> "received " + remoteCapability); + } + } catch (IOException x) { + events.error(x); + } + } + } + + private static class AgentEndpoint extends Endpoint { + private final CompletableFuture futureChannel; + private final EngineListenerSplitter events; + private final String agentName; + private final ExecutorService executor; + private final JarCache jarCache; + private final Supplier capabilitySupplier; + + AgentEndpoint( + String agentName, + ExecutorService executor, + JarCache jarCache, + Supplier capabilitySupplier, + EngineListenerSplitter events) { + this.futureChannel = new CompletableFuture<>(); + this.agentName = agentName; + this.executor = executor; + this.jarCache = jarCache; + this.capabilitySupplier = capabilitySupplier; + this.events = events; + } + + public Future getChannel() { + return futureChannel; + } + + @SuppressFBWarnings(value = "UWF_FIELD_NOT_INITIALIZED_IN_CONSTRUCTOR", justification = "just trust me here") + AgentEndpoint.Transport transport; + + @Override + public void onOpen(Session session, EndpointConfig config) { + events.status("WebSocket connection open"); + session.addMessageHandler(ByteBuffer.class, this::onMessage); + try { + transport = new Transport(session); + futureChannel.complete(new ChannelBuilder(agentName, executor) + .withJarCacheOrDefault(jarCache) + . // unless EngineJnlpConnectionStateListener can be used for this purpose + build(transport)); + } catch (IOException x) { + events.error(x); + } + } + + private void onMessage(ByteBuffer message) { + try { + transport.receive(message); + } catch (IOException x) { + events.error(x); + } catch (InterruptedException x) { + events.error(x); + Thread.currentThread().interrupt(); + } + } + + @Override + @SuppressFBWarnings( + value = "RV_RETURN_VALUE_IGNORED_BAD_PRACTICE", + justification = + "We want the transport.terminate method to run asynchronously and don't want to wait for its status.") + public void onClose(Session session, CloseReason closeReason) { + LOGGER.fine(() -> "onClose: " + closeReason); + // making this call async to avoid potential deadlocks when some thread is holding a lock on the + // channel object while this thread is trying to acquire it to call Transport#terminate + var channel = futureChannel.join(); + channel.executor.submit(() -> transport.terminate(new ChannelClosedException(channel, null))); + } + + @Override + @SuppressFBWarnings( + value = "RV_RETURN_VALUE_IGNORED_BAD_PRACTICE", + justification = + "We want the transport.terminate method to run asynchronously and don't want to wait for its status.") + public void onError(Session session, Throwable x) { + // TODO or would events.error(x) be better? + LOGGER.log(Level.FINE, null, x); + // as above + var channel = futureChannel.join(); + channel.executor.submit(() -> transport.terminate(new ChannelClosedException(channel, x))); + } + + class Transport extends AbstractByteBufferCommandTransport { + final Session session; + + Transport(Session session) { + super(true); + this.session = session; + } + + @Override + protected void write(ByteBuffer headerAndData) throws IOException { + LOGGER.finest(() -> "sending message of length " + (headerAndData.remaining() - ChunkHeader.SIZE)); + try { + session.getAsyncRemote().sendBinary(headerAndData).get(5, TimeUnit.MINUTES); + } catch (Exception x) { + throw new IOException(x); + } + } + + @Override + public Capability getRemoteCapability() { + return capabilitySupplier.get(); + } + + @Override + public void closeWrite() throws IOException { + events.status("Write side closed"); + session.close(); + } + + @Override + public void closeRead() throws IOException { + events.status("Read side closed"); + session.close(); + } + } + } + + @Override + public Future connect() throws Exception { + String localCap = new Capability().toASCII(); + final Map> addedHeaders = new HashMap<>(); + addedHeaders.put(JnlpConnectionState.CLIENT_NAME_KEY, List.of(data.agentName())); + addedHeaders.put(JnlpConnectionState.SECRET_KEY, List.of(data.secretKey())); + addedHeaders.put(Capability.KEY, List.of(localCap)); + for (Map.Entry entry : headers.entrySet()) { + addedHeaders.put(entry.getKey(), List.of(entry.getValue())); + } + String wsUrl = url.toString().replaceFirst("^http", "ws"); + WebSocketContainer container = ContainerProvider.getWebSocketContainer(); + if (container instanceof ClientManager) { + ClientManager client = (ClientManager) container; + + String proxyHost = System.getProperty("http.proxyHost", System.getenv("proxy_host")); + String proxyPort = System.getProperty("http.proxyPort"); + if (proxyHost != null && "http".equals(url.getProtocol()) && NoProxyEvaluator.shouldProxy(url.getHost())) { + URI proxyUri; + if (proxyPort != null) { + proxyUri = URI.create(String.format("http://%s:%s", proxyHost, proxyPort)); + } else { + proxyUri = URI.create(String.format("http://%s", proxyHost)); + } + client.getProperties().put(ClientProperties.PROXY_URI, proxyUri); + if (data.proxyCredentials() != null) { + client.getProperties() + .put( + ClientProperties.PROXY_HEADERS, + Map.of( + "Proxy-Authorization", + "Basic " + + Base64.getEncoder() + .encodeToString(data.proxyCredentials() + .getBytes(StandardCharsets.UTF_8)))); + } + } + + SSLContext sslContext = getSSLContext(data.candidateCertificates(), data.disableHttpsCertValidation()); + if (sslContext != null) { + SslEngineConfigurator sslEngineConfigurator = new SslEngineConfigurator(sslContext); + if (hostnameVerifier != null) { + sslEngineConfigurator.setHostnameVerifier(hostnameVerifier); + } + client.getProperties().put(ClientProperties.SSL_ENGINE_CONFIGURATOR, sslEngineConfigurator); + } + } + return RetryUtils.succeedsWithRetries( + () -> { + var clientEndpointConfigurator = new HeaderHandler(addedHeaders, data.events()); + var endpointInstance = new AgentEndpoint( + data.agentName(), + data.executor(), + data.jarCache(), + () -> clientEndpointConfigurator.remoteCapability, + data.events()); + container.connectToServer( + endpointInstance, + ClientEndpointConfig.Builder.create() + .configurator(clientEndpointConfigurator) + .build(), + URI.create(wsUrl + "wsagents/")); + return endpointInstance.getChannel(); + }, + data.noReconnectAfter(), + data.events()); + } + + @Override + public Boolean waitUntilReady() throws InterruptedException { + return RetryUtils.succeedsWithRetries(this::pingSuccessful, data.noReconnectAfter(), data.events()); + } + + @Override + public String getProtocol() { + return "WebSocket"; + } +} diff --git a/src/main/java/org/jenkinsci/remoting/util/SSLUtils.java b/src/main/java/org/jenkinsci/remoting/util/SSLUtils.java new file mode 100644 index 000000000..83eb4b0e3 --- /dev/null +++ b/src/main/java/org/jenkinsci/remoting/util/SSLUtils.java @@ -0,0 +1,214 @@ +package org.jenkinsci.remoting.util; + +import edu.umd.cs.findbugs.annotations.CheckForNull; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.security.AccessController; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.PrivilegedActionException; +import java.security.PrivilegedExceptionAction; +import java.security.SecureRandom; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import org.jenkinsci.remoting.protocol.cert.DelegatingX509ExtendedTrustManager; +import org.jenkinsci.remoting.util.https.NoCheckTrustManager; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +@Restricted(NoExternalUse.class) +public final class SSLUtils { + private SSLUtils() {} + + private static final Logger LOGGER = Logger.getLogger(SSLUtils.class.getName()); + + @SuppressFBWarnings(value = "PATH_TRAVERSAL_IN", justification = "File path is loaded from system properties.") + static KeyStore getCacertsKeyStore() + throws PrivilegedActionException, KeyStoreException, NoSuchProviderException, CertificateException, + NoSuchAlgorithmException, IOException { + Map properties = + AccessController.doPrivileged((PrivilegedExceptionAction>) () -> { + Map result = new HashMap<>(); + result.put("trustStore", System.getProperty("javax.net.ssl.trustStore")); + result.put("javaHome", System.getProperty("java.home")); + result.put( + "trustStoreType", + System.getProperty("javax.net.ssl.trustStoreType", KeyStore.getDefaultType())); + result.put("trustStoreProvider", System.getProperty("javax.net.ssl.trustStoreProvider", "")); + result.put("trustStorePasswd", System.getProperty("javax.net.ssl.trustStorePassword", "")); + return result; + }); + KeyStore keystore = null; + + FileInputStream trustStoreStream = null; + try { + String trustStore = properties.get("trustStore"); + if (!"NONE".equals(trustStore)) { + File trustStoreFile; + if (trustStore != null) { + trustStoreFile = new File(trustStore); + trustStoreStream = getFileInputStream(trustStoreFile); + } else { + String javaHome = properties.get("javaHome"); + trustStoreFile = new File(javaHome + File.separator + "lib" + File.separator + "security" + + File.separator + "jssecacerts"); + if ((trustStoreStream = getFileInputStream(trustStoreFile)) == null) { + trustStoreFile = new File(javaHome + File.separator + "lib" + File.separator + "security" + + File.separator + "cacerts"); + trustStoreStream = getFileInputStream(trustStoreFile); + } + } + + if (trustStoreStream != null) { + trustStore = trustStoreFile.getPath(); + } else { + trustStore = "No File Available, using empty keystore."; + } + } + + String trustStoreType = properties.get("trustStoreType"); + String trustStoreProvider = properties.get("trustStoreProvider"); + LOGGER.log(Level.FINE, "trustStore is: {0}", trustStore); + LOGGER.log(Level.FINE, "trustStore type is: {0}", trustStoreType); + LOGGER.log(Level.FINE, "trustStore provider is: {0}", trustStoreProvider); + + if (trustStoreType.length() != 0) { + LOGGER.log(Level.FINE, "init truststore"); + + if (trustStoreProvider.length() == 0) { + keystore = KeyStore.getInstance(trustStoreType); + } else { + keystore = KeyStore.getInstance(trustStoreType, trustStoreProvider); + } + + char[] trustStorePasswdChars = null; + String trustStorePasswd = properties.get("trustStorePasswd"); + if (trustStorePasswd.length() != 0) { + trustStorePasswdChars = trustStorePasswd.toCharArray(); + } + + keystore.load(trustStoreStream, trustStorePasswdChars); + if (trustStorePasswdChars != null) { + Arrays.fill(trustStorePasswdChars, (char) 0); + } + } + } finally { + if (trustStoreStream != null) { + trustStoreStream.close(); + } + } + + return keystore; + } + + @CheckForNull + private static FileInputStream getFileInputStream(final File file) throws PrivilegedActionException { + return AccessController.doPrivileged((PrivilegedExceptionAction) () -> { + try { + return file.exists() ? new FileInputStream(file) : null; + } catch (FileNotFoundException e) { + return null; + } + }); + } + + @CheckForNull + @Restricted(NoExternalUse.class) + public static SSLContext getSSLContext(List x509Certificates, boolean noCertificateCheck) + throws PrivilegedActionException, KeyStoreException, NoSuchProviderException, CertificateException, + NoSuchAlgorithmException, IOException, KeyManagementException { + SSLContext sslContext = null; + if (noCertificateCheck) { + sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, new TrustManager[] {new NoCheckTrustManager()}, new SecureRandom()); + } else if (x509Certificates != null && !x509Certificates.isEmpty()) { + KeyStore keyStore = getCacertsKeyStore(); + // load the keystore + keyStore.load(null, null); + int i = 0; + for (X509Certificate c : x509Certificates) { + keyStore.setCertificateEntry(String.format("alias-%d", i++), c); + } + // prepare the trust manager + TrustManagerFactory trustManagerFactory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(keyStore); + // prepare the SSL context + SSLContext ctx = SSLContext.getInstance("TLS"); + // now we have our custom socket factory + ctx.init(null, trustManagerFactory.getTrustManagers(), null); + } + return sslContext; + } + + @CheckForNull + @Restricted(NoExternalUse.class) + public static SSLSocketFactory getSSLSocketFactory( + List x509Certificates, boolean noCertificateCheck) + throws PrivilegedActionException, KeyStoreException, NoSuchProviderException, CertificateException, + NoSuchAlgorithmException, IOException, KeyManagementException { + SSLContext sslContext = getSSLContext(x509Certificates, noCertificateCheck); + return sslContext != null ? sslContext.getSocketFactory() : null; + } + + @SuppressFBWarnings(value = "HARD_CODE_PASSWORD", justification = "Password doesn't need to be protected.") + public static SSLContext createSSLContext(@CheckForNull DelegatingX509ExtendedTrustManager agentTrustManager) + throws IOException { + SSLContext context; + // prepare our SSLContext + try { + context = SSLContext.getInstance("TLS"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Java runtime specification requires support for TLS algorithm", e); + } + char[] password = "password".toCharArray(); + KeyStore store; + try { + store = KeyStore.getInstance(KeyStore.getDefaultType()); + } catch (KeyStoreException e) { + throw new IllegalStateException("Java runtime specification requires support for JKS key store", e); + } + try { + store.load(null, password); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Java runtime specification requires support for JKS key store", e); + } catch (CertificateException e) { + throw new IllegalStateException("Empty keystore", e); + } + KeyManagerFactory kmf; + try { + kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Java runtime specification requires support for default key manager", e); + } + try { + kmf.init(store, password); + } catch (KeyStoreException | NoSuchAlgorithmException | UnrecoverableKeyException e) { + throw new IllegalStateException(e); + } + try { + context.init(kmf.getKeyManagers(), new TrustManager[] {agentTrustManager}, null); + } catch (KeyManagementException e) { + throw new IllegalStateException(e); + } + return context; + } +} diff --git a/src/test/java/hudson/remoting/EngineTest.java b/src/test/java/hudson/remoting/EngineTest.java index 843a13765..85dd85a3e 100644 --- a/src/test/java/hudson/remoting/EngineTest.java +++ b/src/test/java/hudson/remoting/EngineTest.java @@ -146,7 +146,7 @@ public void error(Throwable t) { private static class NoReconnectException extends RuntimeException {} - @Test(timeout = 30_000) + @Test(timeout = 10_000) public void shouldReconnectOnJnlpAgentEndpointResolutionExceptions() { EngineListener l = new TestEngineListener() { private int count;