Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unify two entrypoints #677

Merged
merged 6 commits into from
Oct 20, 2023
Merged

Unify two entrypoints #677

merged 6 commits into from
Oct 20, 2023

Conversation

basil
Copy link
Member

@basil basil commented Oct 13, 2023

Problem

While reading this code recently, I found it quite confusing that there were two entrypoints: java -jar remoting.jar and java -cp remoting.jar hudson.remoting.jnlp.Main. The entrypoint code is a bit messy on both the interface level and the implementation level. There are two unique entrypoints, with quite similar APIs that overlap much of the time but also differ in some minor ways. This is quite confusing; for example, why should the argument be called -noreconnect in one entrypoint and -noReconnect in another, or why should the secret be specified via -secret in one entrypoint and with a positional argument in the other? Similarly, reading the implementation is quite confusing, as in some cases (but not all) the first implementation vectors into the second. So there is not only duplicate code, but also the logic is confusing to follow because sometimes only one implementation is being used, but sometimes two implementations are being used.

Solution

Without breaking compatibility, this PR unifies the two entrypoints into a single implementation, with Main now delegating to Launcher, which now supports both its original semantics and that of Main. While this entails quite a few aliases, the idea is that once all usages are going through the Launcher entrypoint, we can also take the opportunity to standardize on the desired semantics (e.g., -noReconnect instead of -noreconnect, -credentials instead of -jnlpCredentials, etc). The idea is that once this PR is widely adopted, I will go through and update usages to standardize on a common set of calling conventions; for example, named arguments rather than positional arguments, camel case whenever possible, etc. More than just cleaning up the API, this also cleans up the implementations by merging them into one class that is easier to read and understand because the execution is all in one file and can be followed linearly rather than trying to understand what happens when one main method vectors into the other.

Testing done

Apart from mvn clean verify, I've done some light testing against my local controller with both java -jar remoting.jar and java -cp remoting.jar hudson.remoting.jnlp.Main. I also tested Swarm with these changes and tested that the -noCertificateCheck option could be used to connect to a TLS-based Jenkins controller with a self-signed certificate on my local machine. I also ran the core test suite and PCT with these changes.

@basil basil marked this pull request as draft October 13, 2023 02:54
@basil basil force-pushed the entrypoints branch 3 times, most recently from a3d6afb to 3ecf5f6 Compare October 13, 2023 16:43
basil added a commit to basil/jenkins that referenced this pull request Oct 13, 2023
@basil basil force-pushed the entrypoints branch 3 times, most recently from 993610b to bcf78a8 Compare October 13, 2023 20:47
basil added a commit to basil/jenkins that referenced this pull request Oct 13, 2023
basil added a commit to basil/jenkins that referenced this pull request Oct 16, 2023
basil added a commit to basil/bom that referenced this pull request Oct 17, 2023
private SSLSocketFactory getSSLSocketFactory()
@CheckForNull
@Restricted(NoExternalUse.class)
static SSLSocketFactory getSSLSocketFactory(List<X509Certificate> x509Certificates)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Widening the visibility of this method and making static so that it can be reused in another place where this same logic is needed. Also added a @CheckForNull annotation while I was here, since this method can sometimes return null.

Comment on lines +124 to 132
/**
* @deprecated use {@link #secret}, {@link #name}, {@link #urls}, {@link #webSocket}, {@link #tunnel},
* {@link #workDir}, {@link #internalDir}, and/or {@link #failIfWorkDirIsMissing} directly.
*/
@Option(name="-jnlpUrl",usage="instead of talking to the controller via stdin/stdout, " +
"emulate a JNLP client by making a TCP connection to the controller. " +
"Connection parameters are obtained by parsing the JNLP file.")
"Connection parameters are obtained by parsing the JNLP file.", forbids = {"-direct", "-name", "-tunnel", "-url", "-webSocket"})
@Deprecated
public URL agentJnlpURL = null;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The most complicated mode of this plugin is when -jnlpUrl is passed in, which is not done by the Docker inbound agent image used for the Kubernetes plugin but which is recommended when creating an inbound agent in the UI. In this mode, you have to pass some arguments in the command-line invocation, but some other arguments can be provided by the server from the pseudo-JNLP endpoint. This is quite complex and it seems simpler to prefer that all clients simply invoke the tool with the parameters they want rather than relying on the pseudo-JNLP endpoint as a primitive bootstrapping mechanism.

public URL agentJnlpURL = null;

@Option(name="-jnlpCredentials",metaVar="USER:PASSWORD",usage="HTTP BASIC AUTH header to pass in for making HTTP requests.")
@Option(name="-credentials",metaVar="USER:PASSWORD",aliases="-jnlpCredentials",usage="HTTP BASIC AUTH header to pass in for making HTTP requests.")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-credentials in the other entrypoint. Since JNLP is an outdated concept, I think it's better to prefer -credentials as the main name for this argument.

* @deprecated use {@link #agentJnlpCredentials} or {@link #proxyCredentials}
*/
@Deprecated
@Option(name="-auth",metaVar="user:pass",usage="(deprecated) unused; use -credentials or -proxyCredentials")
public String auth = null;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation of this was dead code, which is now deleted. The only ways of passing credentials that actually did anything were -credentials and/or -proxyCredentials.

Comment on lines -220 to -224
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, new TrustManager[]{new NoCheckTrustManager()}, new java.security.SecureRandom());
HttpsURLConnection.setDefaultSSLSocketFactory(context.getSocketFactory());
// bypass host name check, too.
HttpsURLConnection.setDefaultHostnameVerifier(new NoCheckHostnameVerifier());
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This entrypoint set the default SSL socket factory and the default hostname verifier JVM-wide, not just for the HTTP connections made by Remoting. The other entrypoint only set these values for the HTTP connections made by Remoting. Whenever dealing with a matter of security where each entrypoint had different behavior, we always chose the more secure option. Thus, we prefer the behavior of the other entrypoint which does not change JVM-wide behavior.

Comment on lines -553 to -556
if (agentJnlpCredentials != null) {
jnlpArgs.add("-credentials");
jnlpArgs.add(agentJnlpCredentials);
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is actually being kept, just moved next to its siblings for legibility rather than in this random place.

Comment on lines +952 to +1050
@SuppressFBWarnings(value = "PATH_TRAVERSAL_IN", justification = "Parameter supplied by user / administrator.")
private Engine createEngine() {
LOGGER.log(Level.INFO, "Setting up agent: {0}", name);
Engine engine = new Engine(
new CuiListener(),
urls,
secret,
name,
directConnection,
instanceIdentity,
new HashSet<>(protocols));
engine.setWebSocket(webSocket);
if (webSocketHeaders != null) {
engine.setWebSocketHeaders(webSocketHeaders);
}
if (tunnel != null) {
engine.setTunnel(tunnel);
}
if (agentJnlpCredentials != null) {
engine.setCredentials(agentJnlpCredentials);
}
if (proxyCredentials != null) {
engine.setProxyCredentials(proxyCredentials);
}
if (jarCache != null) {
engine.setJarCache(new FileSystemJarCache(jarCache, true));
}
engine.setNoReconnect(noReconnect);
engine.setKeepAlive(!noKeepAlive);

if (noCertificateCheck) {
LOGGER.log(Level.WARNING, "Certificate validation for HTTPs endpoints is disabled");
}
engine.setDisableHttpsCertValidation(noCertificateCheck);

// TODO: ideally logging should be initialized before the "Setting up agent" entry
if (agentLog != null) {
try {
engine.setAgentLog(PathUtils.fileToPath(agentLog));
} catch (IOException ex) {
throw new IllegalStateException("Cannot retrieve custom log destination", ex);
}
}
if (loggingConfigFilePath != null) {
try {
engine.setLoggingConfigFile(PathUtils.fileToPath(loggingConfigFilePath));
} catch (IOException ex) {
throw new IllegalStateException("Logging config file is invalid", ex);
}
}

if (x509Certificates != null && !x509Certificates.isEmpty()) {
engine.setCandidateCertificates(x509Certificates);
}

// Working directory settings
if (workDir != null) {
try {
engine.setWorkDir(PathUtils.fileToPath(workDir));
} catch (IOException ex) {
throw new IllegalStateException("Work directory path is invalid", ex);
}
}
engine.setInternalDir(internalDir);
engine.setFailIfWorkDirIsMissing(failIfWorkDirIsMissing);

return engine;
}

/**
* {@link EngineListener} implementation that sends output to {@link Logger}.
*/
private static final class CuiListener implements EngineListener {
@Override
public void status(String msg, Throwable t) {
LOGGER.log(Level.INFO, msg, t);
}

@Override
public void status(String msg) {
status(msg, null);
}

@Override
@SuppressFBWarnings(
value = "DM_EXIT",
justification = "Yes, we really want to exit in the case of severe error")
public void error(Throwable t) {
LOGGER.log(Level.SEVERE, t.getMessage(), t);
System.exit(-1);
}

@Override
public void onDisconnect() {}

@Override
public void onReconnect() {}
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not original; moved from the other entrypoint.

@@ -100,63 +91,6 @@ static String indent(String s) {
return " " + s.trim().replace("\n", "\n ");
}

/**
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fret not that these public methods are being deleted, as this whole class is @Restricted(NoExternalUse.class). Anyway, these were all duplicate code. Keeping the more widely used copy.

@SuppressFBWarnings(value = "URLCONNECTION_SSRF_FD", justification = "Used by the agent for retrieving connection info from the server.")
static URLConnection openURLConnection(URL url, String credentials, String proxyCredentials,
public static URLConnection openURLConnection(URL url, String credentials, String proxyCredentials,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The more widely used copy of the utility code deleted from the Util class. Visibility is being increased so that this code can be reused.

Comment on lines -553 to -554
//FIXME: Is it really required in this path? Seems like a bug
httpsConnection.setHostnameVerifier(new NoCheckHostnameVerifier());
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As before, whenever it comes to matters of security, we always err on the side of the more restrictive option when unifying the two implementations. Previously, this bug existed in the second entrypoint but not the first (i.e., the second entrypoint was more loose). So if invoking the first entrypoint (which itself invokes the second entrypoint), you got the more restrictive behavior, and if invoking the second entrypoint directly you got the loose behavior. Now that the entrypoints are being unified, I had to choose which behavior to follow. Rather than risk introducing a security issue, I choose the more restrictive behavior. By the way, this can be turned off with -noCertificateCheck (I tested this) and these semantics exactly match Swarm, where there have been no complaints, so I think this is a reasonable change in behavior in a very minor edge case.

Copy link
Member

@jglick jglick left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks right. The more obscure options have no test coverage unfortunately.

src/main/java/hudson/remoting/Launcher.java Outdated Show resolved Hide resolved
src/main/java/hudson/remoting/Launcher.java Outdated Show resolved Hide resolved
docs/inbound-agent.md Show resolved Hide resolved
src/main/java/hudson/remoting/Launcher.java Outdated Show resolved Hide resolved
}
}

private void bootstrapInboundAgent() throws CmdLineException, IOException, InterruptedException {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the long term I think this mode should cease to exist

Agreed; see jenkinsci/docker-swarm-plugin#125 KostyaSha/yet-another-docker-plugin#288 (comment) etc.

src/main/java/hudson/remoting/Launcher.java Show resolved Hide resolved
@basil
Copy link
Member Author

basil commented Oct 20, 2023

I did some more testing with WebSocket, a self-signed certificate, and a local Squid forwarding proxy. All worked fine except for proxy authentication, but no matter which combination of entrypoints and arguments I tried, I couldn't get that to work in the original trunk code either before this PR. I did come up with a fix for proxy authentication, but I will leave that to a separate PR.

@basil basil merged commit 207ec08 into jenkinsci:master Oct 20, 2023
13 checks passed
System.err.println("Most likely a configuration error in the controller");
System.err.println(e.getMessage());
System.exit(1);
if (agentJnlpURL != null || !urls.isEmpty()) {
Copy link
Member

@jglick jglick Oct 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this also needs to cover directConnection, where neither -jnlpUrl nor -url is used yet we are running an inbound agent. jenkinsci/kubernetes-plugin#1451 (comment)#689

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, somehow I missed testing this mode during all the other manual testing I did. Thanks for fixing this so quickly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants