From 5b5040298f5fbf9d5ff7ea7fbfd34f7169e94d75 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Tue, 21 Jan 2020 01:00:39 -0500 Subject: [PATCH] [JEP-222] WebSocket support (#4369) * Playing with WebSocket connections. * Pluggable handlers. * errorWithoutStack * Server-side keepalive pings. * Redesigned to work as an HttpResponse. * Unused dep. * Comment on JnlpSlaveRestarterInstaller. * Sketch of WebSocket-based endpoint for JNLPLauncher. * Comment. * Test enhancement. * Making it more obvious from JNLPLauncherTest where remoting.jar is being loaded from. * Reworked protocol to negotiate remote capabilities. * Unhelpful comment. * Simplifying handshake to use HTTP headers. * Moving code into top-level classes and otherwise prettifying. * Linking to upstream PRs. * Timestamped snapshot + incremental. * Picking up incrementalified build of winstone. * Working around https://github.com/kohsuke/access-modifier/pull/17. * Finally have an incrementalified build of remoting. * Use -webSocket option. * Handling some close and error methods. * Sending X-Remoting-Minimum-Version. * If hudson.remoting.jnlp.Main._main fails, show the error. * Comment. * GUI analogue of https://github.com/jenkinsci/remoting/pull/357/commits/86cea5b83fd2fc93a9fc10896962eb42c95f80a0. * Missing since tags. * Capitalization. * WebSockets.isSupported * Rather than hiding JNLPLauncher.DescriptorImpl when the TCP port is disabled, always display it, but show form validation appropriate to WebSocket or TCP mode. * Form validation fixes. * Minor test improvements. * Reworked WebSocketAgents to be compatible with JnlpAgentReceiver. * Removing WebSocketSession.keepAlive in favor of a global setting applicable to all services. * Add a -webSocket option to the Jenkins CLI. * Using a snapshot deployment of Remoting, pending INFRA-2379. * After #3838 there is no reason to recheck authentication after parsing CLICommand arguments. * Advertise the -webSocket option. * Adapt to newer HtmlUnit. * https://github.com/HtmlUnit/htmlunit/pull/29 seems to have been incompatible. WebClient.addRequestHeader will no longer override a header in a WebRequest, such as when the same WebClient/WebRequest was previously used with different headers. * Tracked down a behavioral change in passing through URL-encoded path characters. https://github.com/HtmlUnit/htmlunit/commit/2c4956863420e4baef9d3d8c23ec0577ec64d2bf picks up https://github.com/apache/httpcomponents-client/commit/8c04c6ae5e5ba1432e40684428338ce68431766b which is the actual change. * Disabled TCP port does not matter in WebSocket mode. * Shade dependencies needed for jenkins-cli.jar. * Help text edit. * https://github.com/jenkinsci/winstone/pull/79 was released as 5.5. * https://github.com/jenkinsci/jenkins-test-harness/pull/183 released as 2.59. * https://github.com/jenkinsci/jenkins/pull/4387#discussion_r362696234 * Bumping remoting to a new deployed snapshot. * Need https://github.com/jenkinsci/winstone/pull/86. * https://github.com/jenkinsci/winstone/pull/86 released as 5.6. * Introduced constant for X-Remoting-Minimum-Version. * s/slave/agent/ in GUI * Removing in-JVM test, as it was no longer useful after introducing shading anyway. * Bump. * No need to check for anonymous CONNECT here. * s/jenkins.slaves/jenkins.agents/g for new code. * Restrict the diagnostic endpoint to administrators. * Fixed Javadoc import after package move. * https://github.com/jenkinsci/remoting/pull/357 released as 4.0. --- cli/pom.xml | 90 ++++-- cli/src/main/java/hudson/cli/CLI.java | 203 +++++++++---- .../java/hudson/cli/PlainCLIProtocol.java | 221 +++++++------- .../hudson/cli/client/Messages.properties | 1 + .../java/hudson/cli/PlainCLIProtocolTest.java | 11 +- core/src/main/java/hudson/cli/CLIAction.java | 275 +++++++++++------- core/src/main/java/hudson/cli/CLICommand.java | 2 - core/src/main/java/hudson/model/Slave.java | 24 +- .../main/java/hudson/slaves/JNLPLauncher.java | 51 ++-- .../java/hudson/triggers/SafeTimerTask.java | 21 ++ .../java/jenkins/agents/WebSocketAgents.java | 191 ++++++++++++ .../slaves/DefaultJnlpSlaveReceiver.java | 4 +- .../jenkins/slaves/JnlpAgentReceiver.java | 3 +- .../java/jenkins/websocket/WebSocketEcho.java | 67 +++++ .../jenkins/websocket/WebSocketSession.java | 150 ++++++++++ .../java/jenkins/websocket/WebSockets.java | 117 ++++++++ .../hudson/TcpSlaveAgentListener/index.jelly | 3 +- .../hudson/cli/CLIAction/example.jelly | 2 +- .../hudson/slaves/JNLPLauncher/config.jelly | 3 + .../slaves/JNLPLauncher/help-webSocket.html | 4 + .../hudson/slaves/JNLPLauncher/main.jelly | 2 +- .../SlaveComputer/slave-agent.jnlp.jelly | 3 + .../slaves/SlaveComputer/systemInfo.jelly | 2 +- pom.xml | 2 +- test/pom.xml | 2 +- .../test/java/hudson/cli/CLIActionTest.java | 14 +- test/src/test/java/hudson/cli/CLITest.java | 4 +- .../java/hudson/slaves/JNLPLauncherTest.java | 6 +- .../jenkins/agents/WebSocketAgentsTest.java | 125 ++++++++ war/pom.xml | 11 +- 30 files changed, 1262 insertions(+), 352 deletions(-) create mode 100644 core/src/main/java/jenkins/agents/WebSocketAgents.java create mode 100644 core/src/main/java/jenkins/websocket/WebSocketEcho.java create mode 100644 core/src/main/java/jenkins/websocket/WebSocketSession.java create mode 100644 core/src/main/java/jenkins/websocket/WebSockets.java create mode 100644 core/src/main/resources/hudson/slaves/JNLPLauncher/help-webSocket.html create mode 100644 test/src/test/java/jenkins/agents/WebSocketAgentsTest.java diff --git a/cli/pom.xml b/cli/pom.xml index c7d2a7839722..c9ca340dbe92 100644 --- a/cli/pom.xml +++ b/cli/pom.xml @@ -53,14 +53,17 @@ org.kohsuke access-modifier-annotation + provided org.jenkins-ci annotation-indexer + true commons-io commons-io + true junit @@ -71,32 +74,41 @@ org.jvnet.localizer localizer 1.26 + true org.apache.sshd sshd-core 1.7.0 - true + true + + + + net.i2p.crypto + eddsa + 0.3.0 + true - - - net.i2p.crypto - eddsa - 0.3.0 - org.slf4j slf4j-jdk14 - true + true + + + org.glassfish.tyrus.bundles + tyrus-standalone-client-jdk + 1.12 + true com.github.spotbugs spotbugs-annotations - true + provided commons-lang commons-lang + true @@ -107,29 +119,43 @@ 2.22.2 - maven-assembly-plugin - - - - - single - - package - - - jar-with-dependencies - - - - hudson.cli.CLI - - - ${project.version} - - - - - + org.apache.maven.plugins + maven-shade-plugin + 3.2.1 + + + package + + shade + + + false + + + javax.websocket + io.jenkins.cli.shaded.javax.websocket + + + org + io.jenkins.cli.shaded.org + + + net + io.jenkins.cli.shaded.net + + + + + hudson.cli.CLI + + ${project.version} + + + + + + + org.jvnet.localizer diff --git a/cli/src/main/java/hudson/cli/CLI.java b/cli/src/main/java/hudson/cli/CLI.java index b66f092448bc..1b9b4856ad7e 100644 --- a/cli/src/main/java/hudson/cli/CLI.java +++ b/cli/src/main/java/hudson/cli/CLI.java @@ -24,6 +24,7 @@ package hudson.cli; import hudson.cli.client.Messages; +import java.io.DataInputStream; import javax.net.ssl.HostnameVerifier; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLContext; @@ -33,22 +34,33 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.net.URI; import java.net.URL; import java.net.URLConnection; +import java.nio.ByteBuffer; import java.nio.charset.Charset; import java.security.GeneralSecurityException; import java.security.KeyPair; import java.security.SecureRandom; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Properties; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.Logger; import static java.util.logging.Level.*; +import javax.websocket.ClientEndpointConfig; +import javax.websocket.Endpoint; +import javax.websocket.EndpointConfig; +import javax.websocket.Session; import org.apache.commons.io.FileUtils; import org.apache.commons.lang.StringUtils; +import org.glassfish.tyrus.client.ClientManager; +import org.glassfish.tyrus.client.ClientProperties; +import org.glassfish.tyrus.container.jdk.client.JdkClientContainer; /** * CLI entry point to Jenkins. @@ -91,7 +103,7 @@ public static void main(final String[] _args) throws Exception { } } - private enum Mode {HTTP, SSH} + private enum Mode {HTTP, SSH, WEB_SOCKET} public static int _main(String[] _args) throws Exception { List args = Arrays.asList(_args); PrivateKeyProvider provider = new PrivateKeyProvider(); @@ -101,8 +113,9 @@ public static int _main(String[] _args) throws Exception { if (url==null) url = System.getenv("HUDSON_URL"); - boolean tryLoadPKey = true; + boolean noKeyAuth = false; + // TODO perhaps allow mode to be defined by environment variable too (assuming $JENKINS_USER_ID can be used for -user) Mode mode = null; String user = null; @@ -137,6 +150,15 @@ public static int _main(String[] _args) throws Exception { args = args.subList(1, args.size()); continue; } + if (head.equals("-webSocket")) { + if (mode != null) { + printUsage("-webSocket clashes with previously defined mode " + mode); + return -1; + } + mode = Mode.WEB_SOCKET; + args = args.subList(1, args.size()); + continue; + } if (head.equals("-remoting")) { printUsage("-remoting mode is no longer supported"); return -1; @@ -161,7 +183,7 @@ public boolean verify(String s, SSLSession sslSession) { continue; } if (head.equals("-noKeyAuth")) { - tryLoadPKey = false; + noKeyAuth = true; args = args.subList(1,args.size()); continue; } @@ -229,9 +251,6 @@ public boolean verify(String s, SSLSession sslSession) { if(args.isEmpty()) args = Arrays.asList("help"); // default to help - if (tryLoadPKey && !provider.hasKeys()) - provider.readFromDefaultLocations(); - if (mode == null) { mode = Mode.HTTP; } @@ -248,6 +267,9 @@ public boolean verify(String s, SSLSession sslSession) { LOGGER.warning("-user required when using -ssh"); return -1; } + if (!noKeyAuth && !provider.hasKeys()) { + provider.readFromDefaultLocations(); + } return SSHCLI.sshConnection(url, user, args, provider, strictHostKey); } @@ -255,6 +277,10 @@ public boolean verify(String s, SSLSession sslSession) { LOGGER.warning("-strictHostKey meaningful only with -ssh"); } + if (noKeyAuth) { + LOGGER.warning("-noKeyAuth meaningful only with -ssh"); + } + if (user != null) { LOGGER.warning("Warning: -user ignored unless using -ssh"); } @@ -271,66 +297,63 @@ public boolean verify(String s, SSLSession sslSession) { return plainHttpConnection(url, args, factory); } + if (mode == Mode.WEB_SOCKET) { + return webSocketConnection(url, args, factory); + } + throw new AssertionError(); } - private static int plainHttpConnection(String url, List args, CLIConnectionFactory factory) throws IOException, InterruptedException { - LOGGER.log(FINE, "Trying to connect to {0} via plain protocol over HTTP", url); - FullDuplexHttpStream streams = new FullDuplexHttpStream(new URL(url), "cli?remoting=false", factory.authorization); - class ClientSideImpl extends PlainCLIProtocol.ClientSide { - boolean complete; - int exit = -1; - ClientSideImpl(InputStream is, OutputStream os) throws IOException { - super(is, os); - if (is.read() != 0) { // cf. FullDuplexHttpService - throw new IOException("expected to see initial zero byte; perhaps you are connecting to an old server which does not support -http?"); - } - } + private static int webSocketConnection(String url, List args, CLIConnectionFactory factory) throws Exception { + LOGGER.fine(() -> "Trying to connect to " + url + " via plain protocol over WebSocket"); + class CLIEndpoint extends Endpoint { @Override - protected void onExit(int code) { - this.exit = code; - finished(); - } + public void onOpen(Session session, EndpointConfig config) {} + } + class Authenticator extends ClientEndpointConfig.Configurator { @Override - protected void onStdout(byte[] chunk) throws IOException { - System.out.write(chunk); + public void beforeRequest(Map> headers) { + if (factory.authorization != null) { + headers.put("Authorization", Collections.singletonList(factory.authorization)); + } } + } + ClientManager client = ClientManager.createClient(JdkClientContainer.class.getName()); // ~ ContainerProvider.getWebSocketContainer() + client.getProperties().put(ClientProperties.REDIRECT_ENABLED, true); // https://tyrus-project.github.io/documentation/1.13.1/index/tyrus-proprietary-config.html#d0e1775 + Session session = client.connectToServer(new CLIEndpoint(), ClientEndpointConfig.Builder.create().configurator(new Authenticator()).build(), URI.create(url.replaceFirst("^http", "ws") + "cli/ws")); + PlainCLIProtocol.Output out = new PlainCLIProtocol.Output() { @Override - protected void onStderr(byte[] chunk) throws IOException { - System.err.write(chunk); + public void send(byte[] data) throws IOException { + session.getBasicRemote().sendBinary(ByteBuffer.wrap(data)); } @Override - protected void handleClose() { - finished(); - } - private synchronized void finished() { - complete = true; - notifyAll(); + public void close() throws IOException { + session.close(); } + }; + try (ClientSideImpl connection = new ClientSideImpl(out)) { + session.addMessageHandler(InputStream.class, is -> { + try { + connection.handle(new DataInputStream(is)); + } catch (IOException x) { + LOGGER.log(Level.WARNING, null, x); + } + }); + connection.start(args); + return connection.exit(); } - try (final ClientSideImpl connection = new ClientSideImpl(streams.getInputStream(), streams.getOutputStream())) { - for (String arg : args) { - connection.sendArg(arg); + } + + private static int plainHttpConnection(String url, List args, CLIConnectionFactory factory) throws IOException, InterruptedException { + LOGGER.log(FINE, "Trying to connect to {0} via plain protocol over HTTP", url); + FullDuplexHttpStream streams = new FullDuplexHttpStream(new URL(url), "cli?remoting=false", factory.authorization); + try (final ClientSideImpl connection = new ClientSideImpl(new PlainCLIProtocol.FramedOutput(streams.getOutputStream()))) { + connection.start(args); + InputStream is = streams.getInputStream(); + if (is.read() != 0) { // cf. FullDuplexHttpService + throw new IOException("expected to see initial zero byte; perhaps you are connecting to an old server which does not support -http?"); } - connection.sendEncoding(Charset.defaultCharset().name()); - connection.sendLocale(Locale.getDefault().toString()); - connection.sendStart(); - connection.begin(); - new Thread("input reader") { - @Override - public void run() { - try { - final OutputStream stdin = connection.streamStdin(); - int c; - while (!connection.complete && (c = System.in.read()) != -1) { - stdin.write(c); - } - connection.sendEndStdin(); - } catch (IOException x) { - LOGGER.log(Level.WARNING, null, x); - } - } - }.start(); + new PlainCLIProtocol.FramedReader(connection, is).start(); new Thread("ping") { // JENKINS-46659 @Override public void run() { @@ -347,13 +370,77 @@ public void run() { } }.start(); - synchronized (connection) { - while (!connection.complete) { - connection.wait(); + return connection.exit(); + } + } + + private static final class ClientSideImpl extends PlainCLIProtocol.ClientSide { + + volatile boolean complete; + private int exit = -1; + + ClientSideImpl(PlainCLIProtocol.Output out) { + super(out); + } + + void start(List args) throws IOException { + for (String arg : args) { + sendArg(arg); + } + sendEncoding(Charset.defaultCharset().name()); + sendLocale(Locale.getDefault().toString()); + sendStart(); + new Thread("input reader") { + @Override + public void run() { + try { + final OutputStream stdin = streamStdin(); + int c; + // TODO check available to avoid sending lots of one-byte frames + while (!complete && (c = System.in.read()) != -1) { + stdin.write(c); + } + sendEndStdin(); + } catch (IOException x) { + LOGGER.log(Level.WARNING, null, x); + } } + }.start(); + } + + @Override + protected synchronized void onExit(int code) { + this.exit = code; + finished(); + } + + @Override + protected void onStdout(byte[] chunk) throws IOException { + System.out.write(chunk); + } + + @Override + protected void onStderr(byte[] chunk) throws IOException { + System.err.write(chunk); + } + + @Override + protected void handleClose() { + finished(); + } + + private synchronized void finished() { + complete = true; + notifyAll(); + } + + synchronized int exit() throws InterruptedException { + while (!complete) { + wait(); } - return connection.exit; + return exit; } + } private static String computeVersion() { diff --git a/cli/src/main/java/hudson/cli/PlainCLIProtocol.java b/cli/src/main/java/hudson/cli/PlainCLIProtocol.java index ed5c453360dd..5359b7c73825 100644 --- a/cli/src/main/java/hudson/cli/PlainCLIProtocol.java +++ b/cli/src/main/java/hudson/cli/PlainCLIProtocol.java @@ -37,6 +37,7 @@ import java.util.logging.Level; import java.util.logging.Logger; import org.apache.commons.io.IOUtils; +import org.apache.commons.io.input.BoundedInputStream; import org.apache.commons.io.input.CountingInputStream; /** @@ -76,111 +77,143 @@ private enum Op { } } - static abstract class EitherSide implements Closeable { + interface Output extends Closeable { + void send(byte[] data) throws IOException; + } - private final CountingInputStream cis; - private final FlightRecorderInputStream flightRecorder; - final DataInputStream dis; - final DataOutputStream dos; + static final class FramedOutput implements Output { - protected EitherSide(InputStream is, OutputStream os) { - cis = new CountingInputStream(is); - flightRecorder = new FlightRecorderInputStream(cis); - dis = new DataInputStream(flightRecorder); + private final DataOutputStream dos; + + FramedOutput(OutputStream os) { dos = new DataOutputStream(os); } - final void begin() { - new Reader().start(); + @Override + public void send(byte[] data) throws IOException { + dos.writeInt(data.length - 1); // not counting the opcode + dos.write(data); + dos.flush(); + } + + @Override + public void close() throws IOException { + dos.close(); } - private class Reader extends Thread { + } - Reader() { - super("PlainCLIProtocol"); // TODO set distinctive Thread.name - } + static final class FramedReader extends Thread { - @Override - public void run() { - try { - while (true) { - LOGGER.finest("reading frame"); - int framelen; - try { - framelen = dis.readInt(); - } catch (EOFException x) { - handleClose(); - break; // TODO verify that we hit EOF immediately, not partway into framelen - } - if (framelen < 0) { - throw new IOException("corrupt stream: negative frame length"); - } - byte b = dis.readByte(); - if (b < 0) { // i.e., >127 - throw new IOException("corrupt stream: negative operation code"); - } - if (b >= Op.values().length) { - LOGGER.log(Level.WARNING, "unknown operation #{0}: {1}", new Object[] {b, HexDump.toHex(flightRecorder.getRecord())}); - IOUtils.skipFully(dis, framelen); - continue; - } - Op op = Op.values()[b]; - long start = cis.getByteCount(); - LOGGER.log(Level.FINEST, "handling frame with {0} of length {1}", new Object[] {op, framelen}); - boolean handled = handle(op, framelen); - if (handled) { - long actuallyRead = cis.getByteCount() - start; - if (actuallyRead != framelen) { - throw new IOException("corrupt stream: expected to read " + framelen + " bytes from " + op + " but read " + actuallyRead); - } - } else { - LOGGER.log(Level.WARNING, "unexpected {0}: {1}", new Object[] {op, HexDump.toHex(flightRecorder.getRecord())}); - IOUtils.skipFully(dis, framelen); + private final EitherSide side; + private final CountingInputStream cis; + private final FlightRecorderInputStream flightRecorder; + private final DataInputStream dis; + + FramedReader(EitherSide side, InputStream is) { + super("PlainCLIProtocol"); // TODO set distinctive Thread.name + this.side = side; + cis = new CountingInputStream(is); + flightRecorder = new FlightRecorderInputStream(cis); + dis = new DataInputStream(flightRecorder); + } + + @Override + public void run() { + try { + while (true) { + LOGGER.finest("reading frame"); + int framelen; + try { + framelen = dis.readInt(); + } catch (EOFException x) { + side.handleClose(); + break; // TODO verify that we hit EOF immediately, not partway into framelen + } + if (framelen < 0) { + throw new IOException("corrupt stream: negative frame length"); + } + LOGGER.finest("read frame length " + framelen); + long start = cis.getByteCount(); + try { + side.handle(new DataInputStream(new BoundedInputStream(dis, /* op byte not counted */framelen + 1))); + } catch (ProtocolException x) { + LOGGER.log(Level.WARNING, null, x); + // but read another frame + } finally { + long actuallyRead = cis.getByteCount() - start; + long unread = framelen + 1 - actuallyRead; + if (unread > 0) { + LOGGER.warning(() -> "Did not read " + unread + " bytes"); + IOUtils.skipFully(dis, unread); } } - } catch (ClosedChannelException x) { - LOGGER.log(Level.FINE, null, x); - handleClose(); - } catch (IOException x) { - LOGGER.log(Level.WARNING, null, flightRecorder.analyzeCrash(x, "broken stream")); - } catch (ReadPendingException x) { - // in case trick in CLIAction does not work - LOGGER.log(Level.FINE, null, x); - handleClose(); - } catch (RuntimeException x) { - LOGGER.log(Level.WARNING, null, x); - handleClose(); } + } catch (ClosedChannelException x) { + LOGGER.log(Level.FINE, null, x); + side.handleClose(); + } catch (IOException x) { + LOGGER.log(Level.WARNING, null, flightRecorder.analyzeCrash(x, "broken stream")); + } catch (ReadPendingException x) { + // in case trick in CLIAction does not work + LOGGER.log(Level.FINE, null, x); + side.handleClose(); + } catch (RuntimeException x) { + LOGGER.log(Level.WARNING, null, x); + side.handleClose(); } + } + } + + private static final class ProtocolException extends IOException { + ProtocolException(String message) { + super(message); } + } - protected abstract void handleClose(); + static abstract class EitherSide implements Closeable { - protected abstract boolean handle(Op op, int framelen) throws IOException; + private final Output out; - private void writeOp(Op op) throws IOException { - dos.writeByte((byte) op.ordinal()); + protected EitherSide(Output out) { + this.out = out; } + protected abstract void handleClose(); + + final void handle(DataInputStream dis) throws IOException { + byte b = dis.readByte(); + if (b < 0) { // i.e., >127 + throw new IOException("corrupt stream: negative operation code"); + } + if (b >= Op.values().length) { + throw new ProtocolException("unknown operation #" + b); + } + Op op = Op.values()[b]; + LOGGER.finest(() -> "handling frame with " + op); + if (!handle(op, dis)) { + throw new ProtocolException("unhandled: " + op); + } + } + + protected abstract boolean handle(Op op, DataInputStream dis) throws IOException; + protected final synchronized void send(Op op) throws IOException { - dos.writeInt(0); - writeOp(op); - dos.flush(); + send(op, new byte[0], 0, 0); } - protected final synchronized void send(Op op, int number) throws IOException { - dos.writeInt(4); - writeOp(op); - dos.writeInt(number); - dos.flush(); + protected final synchronized void send(Op op, int v) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(4); + new DataOutputStream(baos).writeInt(v); + send(op, baos.toByteArray()); } protected final synchronized void send(Op op, byte[] chunk, int off, int len) throws IOException { - dos.writeInt(len); - writeOp(op); - dos.write(chunk, off, len); - dos.flush(); + byte[] data = new byte[len + 1]; + data[0] = (byte) op.ordinal(); + System.arraycopy(chunk, off, data, 1, len); + out.send(data); } protected final void send(Op op, byte[] chunk) throws IOException { @@ -193,13 +226,6 @@ protected final void send(Op op, String text) throws IOException { send(op, buf.toByteArray()); } - protected final byte[] readChunk(int framelen) throws IOException { - assert Thread.currentThread() instanceof EitherSide.Reader; - byte[] buf = new byte[framelen]; - dis.readFully(buf); - return buf; - } - protected final OutputStream stream(final Op op) { return new OutputStream() { @Override @@ -219,20 +245,19 @@ public void write(byte[] b) throws IOException { @Override public synchronized void close() throws IOException { - dos.close(); + out.close(); } } static abstract class ServerSide extends EitherSide { - ServerSide(InputStream is, OutputStream os) { - super(is, os); + ServerSide(Output out) { + super(out); } @Override - protected final boolean handle(Op op, int framelen) throws IOException { - assert Thread.currentThread() instanceof EitherSide.Reader; + protected final boolean handle(Op op, DataInputStream dis) throws IOException { assert op.clientSide; switch (op) { case ARG: @@ -248,7 +273,7 @@ protected final boolean handle(Op op, int framelen) throws IOException { onStart(); return true; case STDIN: - onStdin(readChunk(framelen)); + onStdin(IOUtils.toByteArray(dis)); return true; case END_STDIN: onEndStdin(); @@ -286,23 +311,22 @@ public final OutputStream streamStderr() { static abstract class ClientSide extends EitherSide { - ClientSide(InputStream is, OutputStream os) { - super(is, os); + ClientSide(Output out) { + super(out); } @Override - protected boolean handle(Op op, int framelen) throws IOException { - assert Thread.currentThread() instanceof EitherSide.Reader; + protected boolean handle(Op op, DataInputStream dis) throws IOException { assert !op.clientSide; switch (op) { case EXIT: onExit(dis.readInt()); return true; case STDOUT: - onStdout(readChunk(framelen)); + onStdout(IOUtils.toByteArray(dis)); return true; case STDERR: - onStderr(readChunk(framelen)); + onStderr(IOUtils.toByteArray(dis)); return true; default: return false; @@ -311,6 +335,7 @@ protected boolean handle(Op op, int framelen) throws IOException { protected abstract void onExit(int code); + // TODO more efficient to change signature to InputStream, then use IOUtils.copy protected abstract void onStdout(byte[] chunk) throws IOException; protected abstract void onStderr(byte[] chunk) throws IOException; diff --git a/cli/src/main/resources/hudson/cli/client/Messages.properties b/cli/src/main/resources/hudson/cli/client/Messages.properties index c78903650501..de4cac217441 100644 --- a/cli/src/main/resources/hudson/cli/client/Messages.properties +++ b/cli/src/main/resources/hudson/cli/client/Messages.properties @@ -3,6 +3,7 @@ CLI.Usage=Jenkins CLI\n\ Options:\n\ \ -s URL : the server URL (defaults to the JENKINS_URL env var)\n\ \ -http : use a plain CLI protocol over HTTP(S) (the default; mutually exclusive with -ssh)\n\ + \ -webSocket : like -http but using WebSocket (works better with most reverse proxies)\n\ \ -ssh : use SSH protocol (requires -user; SSH port must be open on server, and user must have registered a public key)\n\ \ -i KEY : SSH private key file used for authentication (for use with -ssh)\n\ \ -noCertificateCheck : bypass HTTPS certificate check entirely. Use with caution\n\ diff --git a/cli/src/test/java/hudson/cli/PlainCLIProtocolTest.java b/cli/src/test/java/hudson/cli/PlainCLIProtocolTest.java index e8381f452ba0..953b01d02ca8 100644 --- a/cli/src/test/java/hudson/cli/PlainCLIProtocolTest.java +++ b/cli/src/test/java/hudson/cli/PlainCLIProtocolTest.java @@ -27,6 +27,7 @@ import org.junit.jupiter.api.Test; import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; import java.io.IOException; import java.io.PipedInputStream; import java.io.PipedOutputStream; @@ -44,7 +45,7 @@ class Client extends PlainCLIProtocol.ClientSide { int code = -1; final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); Client() throws IOException { - super(new PipedInputStream(download), upload); + super(new PlainCLIProtocol.FramedOutput(upload)); } @Override protected synchronized void onExit(int code) { @@ -65,6 +66,7 @@ void send() throws IOException { streamStdin().write("hello".getBytes()); } void newop() throws IOException { + DataOutputStream dos = new DataOutputStream(upload); dos.writeInt(0); dos.writeByte(99); dos.flush(); @@ -75,7 +77,7 @@ class Server extends PlainCLIProtocol.ServerSide { boolean started; final ByteArrayOutputStream stdin = new ByteArrayOutputStream(); Server() throws IOException { - super(new PipedInputStream(upload), download); + super(new PlainCLIProtocol.FramedOutput(download)); } @Override protected void onArg(String text) { @@ -110,6 +112,7 @@ void send() throws IOException { sendExit(2); } void newop() throws IOException { + DataOutputStream dos = new DataOutputStream(download); dos.writeInt(0); dos.writeByte(99); dos.flush(); @@ -117,8 +120,8 @@ void newop() throws IOException { } Client client = new Client(); Server server = new Server(); - client.begin(); - server.begin(); + new PlainCLIProtocol.FramedReader(client, new PipedInputStream(download)).start(); + new PlainCLIProtocol.FramedReader(server, new PipedInputStream(upload)).start(); client.send(); client.newop(); synchronized (server) { diff --git a/core/src/main/java/hudson/cli/CLIAction.java b/core/src/main/java/hudson/cli/CLIAction.java index c3858b6dd183..102dbbc00b0d 100644 --- a/core/src/main/java/hudson/cli/CLIAction.java +++ b/core/src/main/java/hudson/cli/CLIAction.java @@ -43,20 +43,26 @@ import org.kohsuke.stapler.StaplerResponse; import hudson.Extension; +import java.io.ByteArrayInputStream; +import java.io.DataInputStream; import java.io.InputStream; import java.io.OutputStream; import java.io.PipedInputStream; import java.io.PipedOutputStream; import java.io.PrintStream; +import java.nio.ByteBuffer; import java.nio.charset.Charset; import java.nio.charset.UnsupportedCharsetException; import java.util.ArrayList; import java.util.List; import java.util.Locale; -import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; import java.util.logging.Logger; import jenkins.util.FullDuplexHttpService; +import jenkins.websocket.WebSocketSession; +import jenkins.websocket.WebSockets; +import org.acegisecurity.Authentication; +import org.kohsuke.stapler.HttpResponse; import org.kohsuke.stapler.HttpResponses; /** @@ -100,6 +106,74 @@ public void doCommand(StaplerRequest req, StaplerResponse rsp) throws ServletExc req.getView(this, "command.jelly").forward(req, rsp); } + /** for Jelly */ + public boolean isWebSocketSupported() { + return WebSockets.isSupported(); + } + + /** + * WebSocket endpoint. + */ + public HttpResponse doWs() { + if (!WebSockets.isSupported()) { + return HttpResponses.notFound(); + } + Authentication authentication = Jenkins.getAuthentication(); + return WebSockets.upgrade(new WebSocketSession() { + ServerSideImpl connection; + class OutputImpl implements PlainCLIProtocol.Output { + @Override + public void send(byte[] data) throws IOException { + sendBinary(ByteBuffer.wrap(data)); + } + @Override + public void close() throws IOException { + doClose(); + } + } + private void doClose() { + close(); + } + @Override + protected void opened() { + try { + connection = new ServerSideImpl(new OutputImpl(), authentication); + } catch (IOException x) { + error(x); + return; + } + new Thread(() -> { + try { + try { + connection.run(); + } finally { + connection.close(); + } + } catch (Exception x) { + error(x); + } + }, "CLI handler for " + authentication.getName()).start(); + } + @Override + protected void binary(byte[] payload, int offset, int len) { + try { + connection.handle(new DataInputStream(new ByteArrayInputStream(payload, offset, len))); + } catch (IOException x) { + error(x); + } + } + @Override + protected void error(Throwable cause) { + LOGGER.log(Level.WARNING, null, cause); + } + @Override + protected void closed(int statusCode, String reason) { + LOGGER.fine(() -> "closed: " + statusCode + ": " + reason); + connection.handleClose(); + } + }); + } + @Override public Object getTarget() { StaplerRequest req = Stapler.getCurrentRequest(); @@ -116,6 +190,105 @@ public Object getTarget() { } } + class ServerSideImpl extends PlainCLIProtocol.ServerSide { + private Thread runningThread; + private boolean ready; + private final List args = new ArrayList<>(); + private Locale locale = Locale.getDefault(); + private Charset encoding = Charset.defaultCharset(); + private final PipedInputStream stdin = new PipedInputStream(); + private final PipedOutputStream stdinMatch = new PipedOutputStream(); + private final Authentication authentication; + ServerSideImpl(PlainCLIProtocol.Output out, Authentication authentication) throws IOException { + super(out); + stdinMatch.connect(stdin); + this.authentication = authentication; + } + @Override + protected void onArg(String text) { + args.add(text); + } + @Override + protected void onLocale(String text) { + for (Locale _locale : Locale.getAvailableLocales()) { + if (_locale.toString().equals(text)) { + locale = _locale; + return; + } + } + LOGGER.log(Level.WARNING, "unknown client locale {0}", text); + } + @Override + protected void onEncoding(String text) { + try { + encoding = Charset.forName(text); + } catch (UnsupportedCharsetException x) { + LOGGER.log(Level.WARNING, "unknown client charset {0}", text); + } + } + @Override + protected void onStart() { + ready(); + } + @Override + protected void onStdin(byte[] chunk) throws IOException { + stdinMatch.write(chunk); + } + @Override + protected void onEndStdin() throws IOException { + stdinMatch.close(); + } + @Override + protected void handleClose() { + ready(); + if (runningThread != null) { + runningThread.interrupt(); + } + } + private synchronized void ready() { + ready = true; + notifyAll(); + } + void run() throws IOException, InterruptedException { + synchronized (this) { + while (!ready) { + wait(); + } + } + PrintStream stdout = new PrintStream(streamStdout(), false, encoding.name()); + PrintStream stderr = new PrintStream(streamStderr(), true, encoding.name()); + if (args.isEmpty()) { + stderr.println("Connection closed before arguments received"); + sendExit(2); + return; + } + String commandName = args.get(0); + CLICommand command = CLICommand.clone(commandName); + if (command == null) { + stderr.println("No such command " + commandName); + sendExit(2); + return; + } + command.setTransportAuth(authentication); + command.setClientCharset(encoding); + CLICommand orig = CLICommand.setCurrent(command); + try { + runningThread = Thread.currentThread(); + int exit = command.main(args.subList(1, args.size()), locale, stdin, stdout, stderr); + stdout.flush(); + sendExit(exit); + try { // seems to avoid ReadPendingException from Jetty + Thread.sleep(1000); + } catch (InterruptedException x) { + // expected; ignore + } + } finally { + CLICommand.setCurrent(orig); + runningThread = null; + } + } + } + /** * Serves {@link PlainCLIProtocol} response. */ @@ -130,103 +303,9 @@ protected FullDuplexHttpService createService(StaplerRequest req, UUID uuid) thr return new FullDuplexHttpService(uuid) { @Override protected void run(InputStream upload, OutputStream download) throws IOException, InterruptedException { - final AtomicReference runningThread = new AtomicReference<>(); - class ServerSideImpl extends PlainCLIProtocol.ServerSide { - boolean ready; - List args = new ArrayList<>(); - Locale locale = Locale.getDefault(); - Charset encoding = Charset.defaultCharset(); - final PipedInputStream stdin = new PipedInputStream(); - final PipedOutputStream stdinMatch = new PipedOutputStream(); - ServerSideImpl(InputStream is, OutputStream os) throws IOException { - super(is, os); - stdinMatch.connect(stdin); - } - @Override - protected void onArg(String text) { - args.add(text); - } - @Override - protected void onLocale(String text) { - for (Locale _locale : Locale.getAvailableLocales()) { - if (_locale.toString().equals(text)) { - locale = _locale; - return; - } - } - LOGGER.log(Level.WARNING, "unknown client locale {0}", text); - } - @Override - protected void onEncoding(String text) { - try { - encoding = Charset.forName(text); - } catch (UnsupportedCharsetException x) { - LOGGER.log(Level.WARNING, "unknown client charset {0}", text); - } - } - @Override - protected void onStart() { - ready(); - } - @Override - protected void onStdin(byte[] chunk) throws IOException { - stdinMatch.write(chunk); - } - @Override - protected void onEndStdin() throws IOException { - stdinMatch.close(); - } - @Override - protected void handleClose() { - ready(); - Thread t = runningThread.get(); - if (t != null) { - t.interrupt(); - } - } - private synchronized void ready() { - ready = true; - notifyAll(); - } - } - try (ServerSideImpl connection = new ServerSideImpl(upload, download)) { - connection.begin(); - synchronized (connection) { - while (!connection.ready) { - connection.wait(); - } - } - PrintStream stdout = new PrintStream(connection.streamStdout(), false, connection.encoding.name()); - PrintStream stderr = new PrintStream(connection.streamStderr(), true, connection.encoding.name()); - if (connection.args.isEmpty()) { - stderr.println("Connection closed before arguments received"); - connection.sendExit(2); - return; - } - String commandName = connection.args.get(0); - CLICommand command = CLICommand.clone(commandName); - if (command == null) { - stderr.println("No such command " + commandName); - connection.sendExit(2); - return; - } - command.setTransportAuth(Jenkins.getAuthentication()); - command.setClientCharset(connection.encoding); - CLICommand orig = CLICommand.setCurrent(command); - try { - runningThread.set(Thread.currentThread()); - int exit = command.main(connection.args.subList(1, connection.args.size()), connection.locale, connection.stdin, stdout, stderr); - stdout.flush(); - connection.sendExit(exit); - try { // seems to avoid ReadPendingException from Jetty - Thread.sleep(1000); - } catch (InterruptedException x) { - // expected; ignore - } - } finally { - CLICommand.setCurrent(orig); - runningThread.set(null); - } + try (ServerSideImpl connection = new ServerSideImpl(new PlainCLIProtocol.FramedOutput(download), Jenkins.getAuthentication())) { + new PlainCLIProtocol.FramedReader(connection, upload).start(); + connection.run(); } } }; diff --git a/core/src/main/java/hudson/cli/CLICommand.java b/core/src/main/java/hudson/cli/CLICommand.java index 22237a7e47c5..68747a6a8c06 100644 --- a/core/src/main/java/hudson/cli/CLICommand.java +++ b/core/src/main/java/hudson/cli/CLICommand.java @@ -244,8 +244,6 @@ public int main(List args, Locale locale, InputStream stdin, PrintStream if (!(this instanceof HelpCommand || this instanceof WhoAmICommand)) Jenkins.get().checkPermission(Jenkins.READ); p.parseArgument(args.toArray(new String[0])); - if (!(this instanceof HelpCommand || this instanceof WhoAmICommand)) - Jenkins.get().checkPermission(Jenkins.READ); LOGGER.log(Level.FINE, "Invoking CLI command {0}, with {1} arguments, as user {2}.", new Object[] {getName(), args.size(), auth.getName()}); int res = run(); diff --git a/core/src/main/java/hudson/model/Slave.java b/core/src/main/java/hudson/model/Slave.java index 29856dbcfa98..75f88ea92eed 100644 --- a/core/src/main/java/hudson/model/Slave.java +++ b/core/src/main/java/hudson/model/Slave.java @@ -407,23 +407,19 @@ public URL getURL() throws IOException { if (!ALLOWED_JNLPJARS_FILES.contains(name)) { throw new MalformedURLException("The specified file path " + fileName + " is not allowed due to security reasons"); } - + + Class owner = null; if (name.equals("hudson-cli.jar") || name.equals("jenkins-cli.jar")) { - File cliJar = Which.jarFile(CLI.class); - if (cliJar.isFile()) { - name = "jenkins-cli.jar"; - } else { - URL res = findExecutableJar(cliJar, CLI.class); - if (res != null) { - return res; - } - } + owner = CLI.class; } else if (name.equals("agent.jar") || name.equals("slave.jar") || name.equals("remoting.jar")) { - File remotingJar = Which.jarFile(hudson.remoting.Launcher.class); - if (remotingJar.isFile()) { - name = "lib/" + remotingJar.getName(); + owner = hudson.remoting.Launcher.class; + } + if (owner != null) { + File jar = Which.jarFile(owner); + if (jar.isFile()) { + name = "lib/" + jar.getName(); } else { - URL res = findExecutableJar(remotingJar, hudson.remoting.Launcher.class); + URL res = findExecutableJar(jar, owner); if (res != null) { return res; } diff --git a/core/src/main/java/hudson/slaves/JNLPLauncher.java b/core/src/main/java/hudson/slaves/JNLPLauncher.java index 86f38d6b948d..db006ce8bd75 100644 --- a/core/src/main/java/hudson/slaves/JNLPLauncher.java +++ b/core/src/main/java/hudson/slaves/JNLPLauncher.java @@ -27,19 +27,21 @@ import hudson.Util; import hudson.model.Computer; import hudson.model.Descriptor; -import hudson.model.DescriptorVisibilityFilter; import hudson.model.TaskListener; +import hudson.util.FormValidation; import javax.annotation.CheckForNull; import javax.annotation.Nonnull; import jenkins.model.Jenkins; import jenkins.slaves.RemotingWorkDirSettings; import jenkins.util.java.JavaUtils; +import jenkins.websocket.WebSockets; import org.jenkinsci.Symbol; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.DataBoundSetter; +import org.kohsuke.stapler.QueryParameter; /** * {@link ComputerLauncher} via inbound connections. @@ -72,6 +74,8 @@ public class JNLPLauncher extends ComputerLauncher { @Nonnull private RemotingWorkDirSettings workDirSettings = RemotingWorkDirSettings.getEnabledDefaults(); + private boolean webSocket; + /** * Constructor. * @param tunnel Tunnel settings @@ -143,6 +147,21 @@ public boolean isLaunchSupported() { return false; } + /** + * @since TODO + */ + public boolean isWebSocket() { + return webSocket; + } + + /** + * @since TODO + */ + @DataBoundSetter + public void setWebSocket(boolean webSocket) { + this.webSocket = webSocket; + } + @Override public void launch(SlaveComputer computer, TaskListener listener) { // do nothing as we cannot self start @@ -196,25 +215,23 @@ public boolean isWorkDirSupported() { // Causes JENKINS-45895 in the case of includes otherwise return DescriptorImpl.class.equals(getClass()); } - } - - /** - * Hides the JNLP launcher when the JNLP agent port is not enabled. - * - * @since 2.16 - */ - @Extension - public static class DescriptorVisibilityFilterImpl extends DescriptorVisibilityFilter { - @Override - public boolean filter(@CheckForNull Object context, @Nonnull Descriptor descriptor) { - return descriptor.clazz != JNLPLauncher.class || Jenkins.get().getTcpSlaveAgentListener() != null; + public FormValidation doCheckWebSocket(@QueryParameter boolean webSocket, @QueryParameter String tunnel) { + if (webSocket) { + if (!WebSockets.isSupported()) { + return FormValidation.error("WebSocket support is not enabled in this Jenkins installation"); + } + if (Util.fixEmptyAndTrim(tunnel) != null) { + return FormValidation.error("Tunneling is not supported in WebSocket mode"); + } + } else { + if (Jenkins.get().getTcpSlaveAgentListener() == null) { + return FormValidation.error("Either WebSocket mode is selected, or the TCP port for inbound agents must be enabled"); + } + } + return FormValidation.ok(); } - @Override - public boolean filterType(@Nonnull Class contextClass, @Nonnull Descriptor descriptor) { - return descriptor.clazz != JNLPLauncher.class || Jenkins.get().getTcpSlaveAgentListener() != null; - } } /** diff --git a/core/src/main/java/hudson/triggers/SafeTimerTask.java b/core/src/main/java/hudson/triggers/SafeTimerTask.java index 47654b2550db..73409e4ae444 100644 --- a/core/src/main/java/hudson/triggers/SafeTimerTask.java +++ b/core/src/main/java/hudson/triggers/SafeTimerTask.java @@ -50,6 +50,27 @@ */ public abstract class SafeTimerTask extends TimerTask { + /** + * Lambda-friendly means of creating a task. + * @since TODO + */ + public static SafeTimerTask of(ExceptionRunnable r) { + return new SafeTimerTask() { + @Override + protected void doRun() throws Exception { + r.run(); + } + }; + } + /** + * @see #of + * @since TODO + */ + @FunctionalInterface + public interface ExceptionRunnable { + void run() throws Exception; + } + /** * System property to change the location where (tasks) logging should be sent. *

Beware: changing it while Jenkins is running gives no guarantee logs will be sent to the new location diff --git a/core/src/main/java/jenkins/agents/WebSocketAgents.java b/core/src/main/java/jenkins/agents/WebSocketAgents.java new file mode 100644 index 000000000000..965c3b67eb8e --- /dev/null +++ b/core/src/main/java/jenkins/agents/WebSocketAgents.java @@ -0,0 +1,191 @@ +/* + * The MIT License + * + * Copyright 2019 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.agents; + +import com.google.common.collect.ImmutableMap; +import hudson.Extension; +import hudson.ExtensionList; +import hudson.model.Computer; +import hudson.model.InvisibleAction; +import hudson.model.UnprotectedRootAction; +import hudson.remoting.AbstractByteArrayCommandTransport; +import hudson.remoting.Capability; +import hudson.remoting.Channel; +import hudson.remoting.ChannelBuilder; +import hudson.remoting.Engine; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.ClosedChannelException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.Collections; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.slaves.JnlpAgentReceiver; +import jenkins.slaves.RemotingVersionInfo; +import jenkins.websocket.WebSocketSession; +import jenkins.websocket.WebSockets; +import org.jenkinsci.remoting.engine.JnlpConnectionState; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.HttpResponse; +import org.kohsuke.stapler.HttpResponses; +import org.kohsuke.stapler.StaplerRequest; +import org.kohsuke.stapler.StaplerResponse; + +@Extension +@Restricted(NoExternalUse.class) +public final class WebSocketAgents extends InvisibleAction implements UnprotectedRootAction { + + private static final Logger LOGGER = Logger.getLogger(WebSocketAgents.class.getName()); + + @Override + public String getUrlName() { + return WebSockets.isSupported() ? "wsagents" : null; + } + + public HttpResponse doIndex(StaplerRequest req, StaplerResponse rsp) throws IOException { + String agent = req.getHeader(JnlpConnectionState.CLIENT_NAME_KEY); + String secret = req.getHeader(JnlpConnectionState.SECRET_KEY); + String remoteCapabilityStr = req.getHeader(Capability.KEY); + if (agent == null || secret == null || remoteCapabilityStr == null) { + LOGGER.warning(() -> "incomplete headers: " + Collections.list(req.getHeaderNames())); + throw HttpResponses.errorWithoutStack(400, "This endpoint is only for use from agent.jar in WebSocket mode"); + } + LOGGER.fine(() -> "receiving headers: " + Collections.list(req.getHeaderNames())); + if (!JnlpAgentReceiver.DATABASE.exists(agent)) { + LOGGER.warning(() -> "no such agent " + agent); + throw HttpResponses.errorWithoutStack(400, "no such agent"); + } + if (!MessageDigest.isEqual(secret.getBytes(StandardCharsets.US_ASCII), JnlpAgentReceiver.DATABASE.getSecretOf(agent).getBytes(StandardCharsets.US_ASCII))) { + LOGGER.warning(() -> "incorrect secret for " + agent); + throw HttpResponses.forbidden(); + } + JnlpConnectionState state = new JnlpConnectionState(null, ExtensionList.lookup(JnlpAgentReceiver.class)); + state.setRemoteEndpointDescription(req.getRemoteAddr()); + state.fireBeforeProperties(); + LOGGER.fine(() -> "connecting " + agent); + state.fireAfterProperties(ImmutableMap.of( + // TODO or just pass all request headers? + JnlpConnectionState.CLIENT_NAME_KEY, agent, + JnlpConnectionState.SECRET_KEY, secret + )); + Capability remoteCapability = Capability.fromASCII(remoteCapabilityStr); + LOGGER.fine(() -> "received " + remoteCapability); + rsp.setHeader(Capability.KEY, new Capability().toASCII()); + rsp.setHeader(Engine.REMOTING_MINIMUM_VERSION_HEADER, RemotingVersionInfo.getMinimumSupportedVersion().toString()); + rsp.setHeader(JnlpConnectionState.COOKIE_KEY, JnlpAgentReceiver.generateCookie()); // TODO figure out what this is for, if anything + return WebSockets.upgrade(new Session(state, agent, remoteCapability)); + } + + private static class Session extends WebSocketSession { + + private final JnlpConnectionState state; + private final String agent; + private final Capability remoteCapability; + private AbstractByteArrayCommandTransport.ByteArrayReceiver receiver; + + Session(JnlpConnectionState state, String agent, Capability remoteCapability) { + this.state = state; + this.agent = agent; + this.remoteCapability = remoteCapability; + } + + @Override + protected void opened() { + Computer.threadPoolForRemoting.submit(() -> { + LOGGER.fine(() -> "setting up channel for " + agent); + state.fireBeforeChannel(new ChannelBuilder(agent, Computer.threadPoolForRemoting)); + state.fireAfterChannel(state.getChannelBuilder().build(new Transport())); + LOGGER.fine(() -> "set up channel for " + agent); + return null; + }); + } + + @Override + protected void binary(byte[] payload, int offset, int len) { + LOGGER.finest(() -> "reading block of length " + len + " from " + agent); + if (offset == 0 && len == payload.length) { + receiver.handle(payload); + } else { + receiver.handle(Arrays.copyOfRange(payload, offset, offset + len)); + } + } + + @Override + protected void closed(int statusCode, String reason) { + LOGGER.finest(() -> "closed " + statusCode + " " + reason); + IOException x = new ClosedChannelException(); + receiver.terminate(x); + state.fireChannelClosed(x); + state.fireAfterDisconnect(); + } + + @Override + protected void error(Throwable cause) { + LOGGER.log(Level.WARNING, null, cause); + } + + class Transport extends AbstractByteArrayCommandTransport { + + @Override + public void setup(AbstractByteArrayCommandTransport.ByteArrayReceiver bar) { + receiver = bar; + } + + @Override + public void writeBlock(Channel chnl, byte[] bytes) throws IOException { + LOGGER.finest(() -> "writing block of length " + bytes.length + " to " + agent); + try { + sendBinary(ByteBuffer.wrap(bytes)).get(); + } catch (Exception x) { + x.printStackTrace(); + throw new IOException(x); + } + } + + @Override + public Capability getRemoteCapability() throws IOException { + return remoteCapability; + } + + @Override + public void closeWrite() throws IOException { + LOGGER.finest(() -> "closeWrite"); + close(); + } + + @Override + public void closeRead() throws IOException { + LOGGER.finest(() -> "closeRead"); + close(); + } + + } + + } + +} diff --git a/core/src/main/java/jenkins/slaves/DefaultJnlpSlaveReceiver.java b/core/src/main/java/jenkins/slaves/DefaultJnlpSlaveReceiver.java index 140f466a726d..62cebc92d650 100644 --- a/core/src/main/java/jenkins/slaves/DefaultJnlpSlaveReceiver.java +++ b/core/src/main/java/jenkins/slaves/DefaultJnlpSlaveReceiver.java @@ -113,7 +113,7 @@ public void afterProperties(@Nonnull JnlpConnectionState event) { + "Set system property " + "jenkins.slaves.DefaultJnlpSlaveReceiver.disableStrictVerification=true to allow" + "connections until the plugin has been fixed.", - new Object[]{clientName, event.getSocket().getRemoteSocketAddress(), computer.getLauncher().getClass()}); + new Object[]{clientName, event.getRemoteEndpointDescription(), computer.getLauncher().getClass()}); event.reject(new ConnectionRefusalException(String.format("%s is not an inbound agent", clientName))); return; } @@ -149,7 +149,7 @@ public void beforeChannel(@Nonnull JnlpConnectionState event) { final OutputStream log = computer.openLogFile(); state.setLog(log); PrintWriter logw = new PrintWriter(log, true); - logw.println("Inbound agent connected from " + event.getSocket().getInetAddress()); + logw.println("Inbound agent connected from " + event.getRemoteEndpointDescription()); for (ChannelConfigurator cc : ChannelConfigurator.all()) { cc.onChannelBuilding(event.getChannelBuilder(), computer); } diff --git a/core/src/main/java/jenkins/slaves/JnlpAgentReceiver.java b/core/src/main/java/jenkins/slaves/JnlpAgentReceiver.java index 5464056fdbbe..b738b3316a56 100644 --- a/core/src/main/java/jenkins/slaves/JnlpAgentReceiver.java +++ b/core/src/main/java/jenkins/slaves/JnlpAgentReceiver.java @@ -7,12 +7,13 @@ import java.security.SecureRandom; import javax.annotation.Nonnull; +import jenkins.agents.WebSocketAgents; import jenkins.security.HMACConfidentialKey; import org.jenkinsci.remoting.engine.JnlpClientDatabase; import org.jenkinsci.remoting.engine.JnlpConnectionStateListener; /** - * Receives incoming agents connecting through {@link JnlpSlaveAgentProtocol4}. + * Receives incoming agents connecting through the likes of {@link JnlpSlaveAgentProtocol4} or {@link WebSocketAgents}. * *

* This is useful to establish the communication with other JVMs and use them diff --git a/core/src/main/java/jenkins/websocket/WebSocketEcho.java b/core/src/main/java/jenkins/websocket/WebSocketEcho.java new file mode 100644 index 000000000000..5aa8b432c2f8 --- /dev/null +++ b/core/src/main/java/jenkins/websocket/WebSocketEcho.java @@ -0,0 +1,67 @@ +/* + * The MIT License + * + * Copyright 2019 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.websocket; + +import hudson.Extension; +import hudson.model.InvisibleAction; +import hudson.model.RootAction; +import java.nio.ByteBuffer; +import jenkins.model.Jenkins; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.stapler.HttpResponse; + +@Extension +@Restricted(NoExternalUse.class) +public class WebSocketEcho extends InvisibleAction implements RootAction { + + @Override + public String getUrlName() { + return "wsecho"; + } + + public HttpResponse doIndex() { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); + return WebSockets.upgrade(new WebSocketSession() { + @Override + protected void text(String message) { + sendText("hello " + message); + } + @Override + protected void binary(byte[] payload, int offset, int len) { + ByteBuffer data = ByteBuffer.allocate(len); + for (int i = 0; i < len; i++) { + byte b = payload[offset + i]; + if (b >= 'a' && b <= 'z') { + b += 'A' - 'a'; + } + data.put(i, b); + } + sendBinary(data); + } + }); + } + +} diff --git a/core/src/main/java/jenkins/websocket/WebSocketSession.java b/core/src/main/java/jenkins/websocket/WebSocketSession.java new file mode 100644 index 000000000000..95a7ed76094c --- /dev/null +++ b/core/src/main/java/jenkins/websocket/WebSocketSession.java @@ -0,0 +1,150 @@ +/* + * The MIT License + * + * Copyright 2019 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.websocket; + +import java.lang.reflect.Method; +import java.nio.ByteBuffer; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.util.SystemProperties; +import jenkins.util.Timer; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +/** + * One WebSocket connection. + * @see WebSockets + * @since TODO + */ +@Restricted(Beta.class) +public abstract class WebSocketSession { + + /** + * Number of seconds between server-sent pings. + * Zero to disable. + *

nginx docs claim 60s timeout and this seems to match experiments. + * GKE docs says 30s + * but this is a total timeout, not inactivity, so you need to set {@code BackendConfigSpec.timeoutSec} anyway. + *

This is set for the whole Jenkins session rather than a particular service, + * since it has more to do with the environment than anything else. + * Certain services may have their own “keep alive” semantics, + * but for example {@link hudson.remoting.PingThread} may be too infrequent. + */ + private static long PING_INTERVAL_SECONDS = SystemProperties.getLong("jenkins.websocket.pingInterval", 30L); + + private static final Logger LOGGER = Logger.getLogger(WebSocketSession.class.getName()); + + private Object session; + private Object remoteEndpoint; + private ScheduledFuture pings; + + protected WebSocketSession() {} + + Object onWebSocketSomething(Object proxy, Method method, Object[] args) throws Exception { + switch (method.getName()) { + case "onWebSocketConnect": + this.session = args[0]; + this.remoteEndpoint = session.getClass().getMethod("getRemote").invoke(args[0]); + if (PING_INTERVAL_SECONDS != 0) { + pings = Timer.get().scheduleAtFixedRate(() -> { + try { + remoteEndpoint.getClass().getMethod("sendPing", ByteBuffer.class).invoke(remoteEndpoint, ByteBuffer.wrap(new byte[0])); + } catch (Exception x) { + error(x); + pings.cancel(true); + } + }, PING_INTERVAL_SECONDS / 2, PING_INTERVAL_SECONDS, TimeUnit.SECONDS); + } + opened(); + return null; + case "onWebSocketClose": + if (pings != null) { + pings.cancel(true); + // alternately, check Session.isOpen each time + } + closed((Integer) args[0], (String) args[1]); + return null; + case "onWebSocketError": + error((Throwable) args[0]); + return null; + case "onWebSocketBinary": + binary((byte[]) args[0], (Integer) args[1], (Integer) args[2]); + return null; + case "onWebSocketText": + text((String) args[0]); + return null; + default: + throw new AssertionError(); + } + } + + protected void opened() { + } + + protected void closed(int statusCode, String reason) { + } + + protected void error(Throwable cause) { + LOGGER.log(Level.WARNING, "unhandled WebSocket service error", cause); + } + + protected void binary(byte[] payload, int offset, int len) { + LOGGER.warning("unexpected binary frame"); + } + + protected void text(String message) { + LOGGER.warning("unexpected text frame"); + } + + @SuppressWarnings("unchecked") + protected final Future sendBinary(ByteBuffer data) { + try { + return (Future) remoteEndpoint.getClass().getMethod("sendBytesByFuture", ByteBuffer.class).invoke(remoteEndpoint, data); + } catch (Exception x) { + throw new RuntimeException(x); + } + } + + @SuppressWarnings("unchecked") + protected final Future sendText(String text) { + try { + return (Future) remoteEndpoint.getClass().getMethod("sendStringByFuture", String.class).invoke(remoteEndpoint, text); + } catch (Exception x) { + throw new RuntimeException(x); + } + } + + protected final void close() { + try { + session.getClass().getMethod("close").invoke(session); + } catch (Exception x) { + throw new RuntimeException(x); + } + } + +} diff --git a/core/src/main/java/jenkins/websocket/WebSockets.java b/core/src/main/java/jenkins/websocket/WebSockets.java new file mode 100644 index 000000000000..2d856d5069cb --- /dev/null +++ b/core/src/main/java/jenkins/websocket/WebSockets.java @@ -0,0 +1,117 @@ +/* + * The MIT License + * + * Copyright 2019 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.websocket; + +import hudson.Extension; +import hudson.ExtensionList; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.servlet.ServletContext; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; +import org.kohsuke.stapler.HttpResponse; +import org.kohsuke.stapler.HttpResponses; +import org.kohsuke.stapler.Stapler; + +/** + * Support for serving WebSocket responses. + * @since TODO + */ +@Restricted(Beta.class) +@Extension +public class WebSockets { + + private static final Logger LOGGER = Logger.getLogger(WebSockets.class.getName()); + + private static final String ATTR_SESSION = WebSockets.class.getName() + ".session"; + + // TODO ability to handle subprotocols? + + public static HttpResponse upgrade(WebSocketSession session) { + return (req, rsp, node) -> { + try { + Object factory = ExtensionList.lookupSingleton(WebSockets.class).init(); + if (!((Boolean) webSocketServletFactoryClass.getMethod("isUpgradeRequest", HttpServletRequest.class, HttpServletResponse.class).invoke(factory, req, rsp))) { + throw HttpResponses.errorWithoutStack(HttpServletResponse.SC_BAD_REQUEST, "only WS connections accepted here"); + } + req.setAttribute(ATTR_SESSION, session); + if (!((Boolean) webSocketServletFactoryClass.getMethod("acceptWebSocket", HttpServletRequest.class, HttpServletResponse.class).invoke(factory, req, rsp))) { + throw HttpResponses.errorWithoutStack(HttpServletResponse.SC_BAD_REQUEST, "did not manage to upgrade"); + } + } catch (HttpResponses.HttpResponseException x) { + throw x; + } catch (Exception x) { + LOGGER.log(Level.WARNING, null, x); + throw HttpResponses.error(x); + } + // OK! + }; + } + + private static ClassLoader cl; + private static Class webSocketServletFactoryClass; + + private static synchronized void staticInit() throws Exception { + if (webSocketServletFactoryClass == null) { + cl = ServletContext.class.getClassLoader(); + webSocketServletFactoryClass = cl.loadClass("org.eclipse.jetty.websocket.servlet.WebSocketServletFactory"); + } + } + + public static boolean isSupported() { + try { + staticInit(); + return true; + } catch (Exception x) { + LOGGER.log(Level.FINE, null, x); + return false; + } + } + + private /*WebSocketServletFactory*/Object factory; + + private synchronized Object init() throws Exception { + if (factory == null) { + staticInit(); + Class webSocketPolicyClass = cl.loadClass("org.eclipse.jetty.websocket.api.WebSocketPolicy"); + factory = cl.loadClass("org.eclipse.jetty.websocket.servlet.WebSocketServletFactory$Loader").getMethod("load", ServletContext.class, webSocketPolicyClass).invoke(null, Stapler.getCurrent().getServletContext(), webSocketPolicyClass.getMethod("newServerPolicy").invoke(null)); + webSocketServletFactoryClass.getMethod("start").invoke(factory); + Class webSocketCreatorClass = cl.loadClass("org.eclipse.jetty.websocket.servlet.WebSocketCreator"); + webSocketServletFactoryClass.getMethod("setCreator", webSocketCreatorClass).invoke(factory, Proxy.newProxyInstance(cl, new Class[] {webSocketCreatorClass}, this::createWebSocket)); + } + return factory; + } + + private Object createWebSocket(Object proxy, Method method, Object[] args) throws Exception { + Object servletUpgradeRequest = args[0]; + WebSocketSession session = (WebSocketSession) servletUpgradeRequest.getClass().getMethod("getServletAttribute", String.class).invoke(servletUpgradeRequest, ATTR_SESSION); + return Proxy.newProxyInstance(cl, new Class[] {cl.loadClass("org.eclipse.jetty.websocket.api.WebSocketListener")}, session::onWebSocketSomething); + } + +} diff --git a/core/src/main/resources/hudson/TcpSlaveAgentListener/index.jelly b/core/src/main/resources/hudson/TcpSlaveAgentListener/index.jelly index b837be4264e7..1b04d3c2c9ae 100644 --- a/core/src/main/resources/hudson/TcpSlaveAgentListener/index.jelly +++ b/core/src/main/resources/hudson/TcpSlaveAgentListener/index.jelly @@ -40,7 +40,8 @@ THE SOFTWARE. - + + Jenkins diff --git a/core/src/main/resources/hudson/cli/CLIAction/example.jelly b/core/src/main/resources/hudson/cli/CLIAction/example.jelly index a7e6e2cc10b8..5a4c4cef320f 100644 --- a/core/src/main/resources/hudson/cli/CLIAction/example.jelly +++ b/core/src/main/resources/hudson/cli/CLIAction/example.jelly @@ -34,6 +34,6 @@ THE SOFTWARE. } -

java -jar jenkins-cli.jar -s ${h.inferHudsonURL(request)} ${commandArgs}
+
java -jar jenkins-cli.jar -s ${h.inferHudsonURL(request)} -webSocket ${commandArgs}
diff --git a/core/src/main/resources/hudson/slaves/JNLPLauncher/config.jelly b/core/src/main/resources/hudson/slaves/JNLPLauncher/config.jelly index d0765ddb5b5e..6b33de6b8068 100644 --- a/core/src/main/resources/hudson/slaves/JNLPLauncher/config.jelly +++ b/core/src/main/resources/hudson/slaves/JNLPLauncher/config.jelly @@ -28,6 +28,9 @@ THE SOFTWARE. + + + diff --git a/core/src/main/resources/hudson/slaves/JNLPLauncher/help-webSocket.html b/core/src/main/resources/hudson/slaves/JNLPLauncher/help-webSocket.html new file mode 100644 index 000000000000..90f5c28f9077 --- /dev/null +++ b/core/src/main/resources/hudson/slaves/JNLPLauncher/help-webSocket.html @@ -0,0 +1,4 @@ +
+ Use WebSocket to connect to the Jenkins master rather than the TCP port. + See JEP-222 for background. +
diff --git a/core/src/main/resources/hudson/slaves/JNLPLauncher/main.jelly b/core/src/main/resources/hudson/slaves/JNLPLauncher/main.jelly index 134837393300..404e30b9f0f0 100644 --- a/core/src/main/resources/hudson/slaves/JNLPLauncher/main.jelly +++ b/core/src/main/resources/hudson/slaves/JNLPLauncher/main.jelly @@ -25,7 +25,7 @@ THE SOFTWARE. - +
${%slaveAgentPort.disabled} ${%configure.link.text}. diff --git a/core/src/main/resources/hudson/slaves/SlaveComputer/slave-agent.jnlp.jelly b/core/src/main/resources/hudson/slaves/SlaveComputer/slave-agent.jnlp.jelly index e07b78974141..173a897e9c2d 100644 --- a/core/src/main/resources/hudson/slaves/SlaveComputer/slave-agent.jnlp.jelly +++ b/core/src/main/resources/hudson/slaves/SlaveComputer/slave-agent.jnlp.jelly @@ -63,6 +63,9 @@ THE SOFTWARE. ${it.jnlpMac} ${it.node.nodeName} + + -webSocket + -tunnel ${launcher.tunnel} diff --git a/core/src/main/resources/hudson/slaves/SlaveComputer/systemInfo.jelly b/core/src/main/resources/hudson/slaves/SlaveComputer/systemInfo.jelly index 279fc74807f6..8fa1b2ba2f29 100644 --- a/core/src/main/resources/hudson/slaves/SlaveComputer/systemInfo.jelly +++ b/core/src/main/resources/hudson/slaves/SlaveComputer/systemInfo.jelly @@ -45,7 +45,7 @@ THE SOFTWARE. -

${it.oSDescription} slave, version ${it.slaveVersion}

+

${it.oSDescription} agent, version ${it.slaveVersion}

${instance.displayName}

diff --git a/pom.xml b/pom.xml index 392fbbee82dc..567a6e4fff63 100755 --- a/pom.xml +++ b/pom.xml @@ -101,7 +101,7 @@ THE SOFTWARE. 3.2.3 - 3.40 + 4.0 3.14 diff --git a/test/pom.xml b/test/pom.xml index a6a278179200..18326007370d 100644 --- a/test/pom.xml +++ b/test/pom.xml @@ -70,7 +70,7 @@ THE SOFTWARE. ${project.groupId} jenkins-test-harness - 2.58 + 2.59 test diff --git a/test/src/test/java/hudson/cli/CLIActionTest.java b/test/src/test/java/hudson/cli/CLIActionTest.java index 4e7e2a7acc85..f3840459b815 100644 --- a/test/src/test/java/hudson/cli/CLIActionTest.java +++ b/test/src/test/java/hudson/cli/CLIActionTest.java @@ -20,7 +20,6 @@ import java.net.HttpURLConnection; import java.util.Arrays; import java.util.List; -import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import jenkins.model.Jenkins; @@ -53,8 +52,6 @@ public class CLIActionTest { @Rule public LoggerRule logging = new LoggerRule(); - private ExecutorService pool; - @Test @PresetData(DataSet.NO_ANONYMOUS_READACCESS) @Issue("SECURITY-192") @@ -87,7 +84,6 @@ public void authentication() throws Exception { // @CLIMethod: assertExitCode(6, false, jar, "disable-job", "p"); // AccessDeniedException from CLIRegisterer? assertExitCode(0, true, jar, "disable-job", "p"); - // If we have anonymous read access, then the situation is simpler. j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy().grant(Jenkins.ADMINISTER).everywhere().to(ADMIN).grant(Jenkins.READ, Item.READ).everywhere().toEveryone()); assertExitCode(6, false, jar, "get-job", "p"); // AccessDeniedException from AbstractItem.writeConfigDotXml assertExitCode(0, true, jar, "get-job", "p"); // works with API tokens @@ -98,7 +94,7 @@ public void authentication() throws Exception { private static final String ADMIN = "admin@mycorp.com"; private void assertExitCode(int code, boolean useApiToken, File jar, String... args) throws IOException, InterruptedException { - List commands = Lists.newArrayList("java", "-jar", jar.getAbsolutePath(), "-s", j.getURL().toString(), /* not covering SSH keys in this test */ "-noKeyAuth"); + List commands = Lists.newArrayList("java", "-jar", jar.getAbsolutePath(), "-s", j.getURL().toString(), /* TODO until it is the default */ "-webSocket"); if (useApiToken) { commands.add("-auth"); commands.add(ADMIN + ":" + User.get(ADMIN).getProperty(ApiTokenProperty.class).getApiToken()); @@ -137,7 +133,8 @@ public void encodingAndLocale() throws Exception { ByteArrayOutputStream baos = new ByteArrayOutputStream(); assertEquals(0, new Launcher.LocalLauncher(StreamTaskListener.fromStderr()).launch().cmds( "java", "-Dfile.encoding=ISO-8859-2", "-Duser.language=cs", "-Duser.country=CZ", "-jar", jar.getAbsolutePath(), - "-s", j.getURL().toString()./* just checking */replaceFirst("/$", ""), "-noKeyAuth", "test-diagnostic"). + "-webSocket", // TODO as above + "-s", j.getURL().toString()./* just checking */replaceFirst("/$", ""), "test-diagnostic"). stdout(baos).stderr(System.err).join()); assertEquals("encoding=ISO-8859-2 locale=cs_CZ", baos.toString().trim()); // TODO test that stdout/stderr are in expected encoding (not true of -remoting mode!) @@ -155,7 +152,9 @@ public void interleavedStdio() throws Exception { PipedOutputStream pos = new PipedOutputStream(pis); PrintWriter pw = new PrintWriter(new TeeOutputStream(pos, System.err), true); Proc proc = new Launcher.LocalLauncher(StreamTaskListener.fromStderr()).launch().cmds( - "java", "-jar", jar.getAbsolutePath(), "-s", j.getURL().toString(), "-noKeyAuth", "groovysh"). + "java", "-jar", jar.getAbsolutePath(), "-s", j.getURL().toString(), + "-webSocket", // TODO as above + "groovysh"). stdout(new TeeOutputStream(baos, System.out)).stderr(System.err).stdin(pis).start(); while (!baos.toString().contains("000")) { // cannot just search for, say, "groovy:000> " since there are ANSI escapes there (cf. StringEscapeUtils.escapeJava) Thread.sleep(100); @@ -164,7 +163,6 @@ public void interleavedStdio() throws Exception { while (!baos.toString().contains("121")) { // ditto not "===> 121" Thread.sleep(100); } - Thread.sleep(31_000); // aggravate org.eclipse.jetty.io.IdleTimeout (cf. AbstractConnector._idleTimeout) pw.println("11 * 11 * 11"); while (!baos.toString().contains("1331")) { Thread.sleep(100); diff --git a/test/src/test/java/hudson/cli/CLITest.java b/test/src/test/java/hudson/cli/CLITest.java index 9f62ebb996ee..ca541bc95007 100644 --- a/test/src/test/java/hudson/cli/CLITest.java +++ b/test/src/test/java/hudson/cli/CLITest.java @@ -157,6 +157,7 @@ public void interrupt() throws Exception { p.getBuildersList().add(new SleepBuilder(TimeUnit.MINUTES.toMillis(5))); doInterrupt(p, "-ssh", "-user", "admin", "-i", privkey.getAbsolutePath()); doInterrupt(p, "-http", "-auth", "admin:admin"); + doInterrupt(p, "-webSocket", "-auth", "admin:admin"); } private void doInterrupt(FreeStyleProject p, String... modeArgs) throws Exception { ByteArrayOutputStream baos = new ByteArrayOutputStream(); @@ -191,6 +192,7 @@ public void reportNotJenkins() throws Exception { assertThat(baos.toString(), containsString("There's no Jenkins running at")); assertNotEquals(0, ret); } + // TODO -webSocket currently produces a stack trace } @TestExtension("reportNotJenkins") public static final class NoJenkinsAction extends CrumbExclusion implements UnprotectedRootAction, StaplerProxy { @@ -244,7 +246,7 @@ public void redirectToEndpointShouldBeFollowed() throws Exception { assertNull(rsp.getContentAsString(), rsp.getResponseHeaderValue("X-Jenkins-CLI-Port")); assertNull(rsp.getContentAsString(), rsp.getResponseHeaderValue("X-SSH-Endpoint")); - for (String transport: Arrays.asList("-http", "-ssh")) { + for (String transport: Arrays.asList("-http", "-ssh", "-webSocket")) { String url = r.getURL().toString() + "cli-proxy/"; ByteArrayOutputStream baos = new ByteArrayOutputStream(); diff --git a/test/src/test/java/hudson/slaves/JNLPLauncherTest.java b/test/src/test/java/hudson/slaves/JNLPLauncherTest.java index 31110c12adac..a60c4dc68786 100644 --- a/test/src/test/java/hudson/slaves/JNLPLauncherTest.java +++ b/test/src/test/java/hudson/slaves/JNLPLauncherTest.java @@ -56,9 +56,11 @@ import static org.junit.Assert.fail; import java.awt.*; +import java.util.logging.Level; import static org.hamcrest.Matchers.instanceOf; import org.junit.rules.TemporaryFolder; import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.LoggerRule; import org.jvnet.hudson.test.recipes.LocalData; /** @@ -69,7 +71,9 @@ public class JNLPLauncherTest { @Rule public JenkinsRule j = new JenkinsRule(); - @Rule public TemporaryFolder tmpDir = new TemporaryFolder(); + @Rule public TemporaryFolder tmpDir = new TemporaryFolder(); + + @Rule public LoggerRule logging = new LoggerRule().record(Slave.class, Level.FINE); /** * Starts a JNLP agent and makes sure it successfully connects to Jenkins. diff --git a/test/src/test/java/jenkins/agents/WebSocketAgentsTest.java b/test/src/test/java/jenkins/agents/WebSocketAgentsTest.java new file mode 100644 index 000000000000..37c47dcfd388 --- /dev/null +++ b/test/src/test/java/jenkins/agents/WebSocketAgentsTest.java @@ -0,0 +1,125 @@ +/* + * The MIT License + * + * Copyright 2019 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.agents; + +import hudson.Functions; +import hudson.Proc; +import hudson.model.FreeStyleProject; +import hudson.model.Slave; +import hudson.remoting.Engine; +import hudson.slaves.DumbSlave; +import hudson.slaves.JNLPLauncher; +import hudson.slaves.SlaveComputer; +import hudson.tasks.BatchFile; +import hudson.tasks.Shell; +import java.io.File; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Level; +import java.util.logging.Logger; +import jenkins.security.SlaveToMasterCallable; +import org.apache.commons.io.FileUtils; +import org.apache.tools.ant.util.JavaEnvUtils; +import org.junit.ClassRule; +import org.junit.Test; +import static org.junit.Assert.*; +import org.junit.Rule; +import org.junit.rules.TemporaryFolder; +import org.jvnet.hudson.test.BuildWatcher; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.LoggerRule; + +@Issue("JEP-222") +public class WebSocketAgentsTest { + + private static final Logger LOGGER = Logger.getLogger(WebSocketAgentsTest.class.getName()); + + @ClassRule + public static BuildWatcher buildWatcher = new BuildWatcher(); + + @Rule + public JenkinsRule r = new JenkinsRule(); + + @Rule + public LoggerRule logging = new LoggerRule(). + record(Slave.class, Level.FINE). + record(SlaveComputer.class, Level.FINEST). + record(WebSocketAgents.class, Level.FINEST). + record(Engine.class, Level.FINEST); + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + /** + * Verify basic functionality of an agent in {@code -webSocket} mode. + * Requires {@code remoting} to have been {@code mvn install}ed. + * Does not show {@code FINE} or lower agent logs ({@link JenkinsRule#showAgentLogs(Slave, LoggerRule)} cannot be used here). + * Unlike {@link hudson.slaves.JNLPLauncherTest} this does not use {@code javaws}; + * closer to {@link hudson.bugs.JnlpAccessWithSecuredHudsonTest}. + * @see hudson.remoting.Launcher + */ + @SuppressWarnings("ResultOfMethodCallIgnored") + @Test + public void smokes() throws Exception { + AtomicReference proc = new AtomicReference<>(); + try { + JNLPLauncher launcher = new JNLPLauncher(true); + launcher.setWebSocket(true); + DumbSlave s = new DumbSlave("remote", tmp.newFolder("agent").getAbsolutePath(), launcher); + r.jenkins.addNode(s); + String secret = ((SlaveComputer) s.toComputer()).getJnlpMac(); + File slaveJar = tmp.newFile(); + FileUtils.copyURLToFile(new Slave.JnlpJar("slave.jar").getURL(), slaveJar); + proc.set(r.createLocalLauncher().launch().cmds( + JavaEnvUtils.getJreExecutable("java"), "-jar", slaveJar.getAbsolutePath(), + "-jnlpUrl", r.getURL() + "computer/remote/slave-agent.jnlp", + "-secret", secret + ).stdout(System.out).start()); + r.waitOnline(s); + assertEquals("response", s.getChannel().call(new DummyTask())); + FreeStyleProject p = r.createFreeStyleProject(); + p.setAssignedNode(s); + p.getBuildersList().add(Functions.isWindows() ? new BatchFile("echo hello") : new Shell("echo hello")); + r.buildAndAssertSuccess(p); + s.toComputer().getLogText().writeLogTo(0, System.out); + } finally { + if (proc.get() != null) { + proc.get().kill(); + while (r.jenkins.getComputer("remote").isOnline()) { + LOGGER.info("waiting for computer to go offline"); + Thread.sleep(250); + } + } + } + } + + private static class DummyTask extends SlaveToMasterCallable { + @Override + public String call() { + return "response"; + } + } + +} diff --git a/war/pom.xml b/war/pom.xml index 934be6cf4df2..da3705dd924c 100644 --- a/war/pom.xml +++ b/war/pom.xml @@ -91,9 +91,7 @@ THE SOFTWARE. ${project.groupId} cli - jar-with-dependencies ${project.version} - provided org.jenkins-ci winstone - 5.4 + 5.6 test @@ -262,13 +260,6 @@ THE SOFTWARE. - - ${project.groupId} - cli - jar-with-dependencies - ${project.build.directory}/${project.build.finalName}/WEB-INF - jenkins-cli.jar - org.jenkins-ci winstone