From 829d73922b6fa442391f3892d3545041da7f88e5 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Mon, 4 Nov 2019 17:29:26 -0500 Subject: [PATCH 01/59] Playing with WebSocket connections. --- core/pom.xml | 6 ++ .../main/java/jenkins/WebSocketEndpoint.java | 78 +++++++++++++++++++ war/pom.xml | 2 +- 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 core/src/main/java/jenkins/WebSocketEndpoint.java diff --git a/core/pom.xml b/core/pom.xml index 0402553742ec..b8997ddcd17f 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -284,6 +284,12 @@ THE SOFTWARE. 3.1.0 provided + commons-io commons-io diff --git a/core/src/main/java/jenkins/WebSocketEndpoint.java b/core/src/main/java/jenkins/WebSocketEndpoint.java new file mode 100644 index 000000000000..a849294cb16e --- /dev/null +++ b/core/src/main/java/jenkins/WebSocketEndpoint.java @@ -0,0 +1,78 @@ +/* + * 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; + +import hudson.Extension; +import hudson.model.InvisibleAction; +import hudson.model.UnprotectedRootAction; +import java.lang.reflect.Proxy; +import javax.servlet.ServletContext; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import org.kohsuke.stapler.HttpResponses; +import org.kohsuke.stapler.Stapler; +import org.kohsuke.stapler.StaplerRequest; +import org.kohsuke.stapler.StaplerResponse; + +@Extension +public final class WebSocketEndpoint extends InvisibleAction implements UnprotectedRootAction { + + private Class webSocketServletFactoryClass; + private /*WebSocketServletFactory*/Object factory; + + @Override + public String getUrlName() { + return "ws"; + } + + private synchronized void init() throws Exception { + if (factory == null) { + ClassLoader cl = ServletContext.class.getClassLoader(); + webSocketServletFactoryClass = cl.loadClass("org.eclipse.jetty.websocket.servlet.WebSocketServletFactory"); + 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}, (proxy1, method1, args1) -> { + return Proxy.newProxyInstance(cl, new Class[] {cl.loadClass("org.eclipse.jetty.websocket.api.WebSocketListener")}, (proxy2, method2, args2) -> { + System.err.println("TODO running " + method2.getName()); + return null; + }); + })); + } + } + + public void doDynamic(StaplerRequest req, StaplerResponse rsp) throws Exception { + init(); + if (!((Boolean) webSocketServletFactoryClass.getMethod("isUpgradeRequest", HttpServletRequest.class, HttpServletResponse.class).invoke(factory, req, rsp))) { + throw HttpResponses.error(HttpServletResponse.SC_BAD_REQUEST, "only WS connections accepted here"); + } + if (!((Boolean) webSocketServletFactoryClass.getMethod("acceptWebSocket", HttpServletRequest.class, HttpServletResponse.class).invoke(factory, req, rsp))) { + throw HttpResponses.error(HttpServletResponse.SC_BAD_REQUEST, "did not manage to upgrade"); + } + // OK! + } + +} diff --git a/war/pom.xml b/war/pom.xml index d8b5519fc8cc..339b80018ffb 100644 --- a/war/pom.xml +++ b/war/pom.xml @@ -101,7 +101,7 @@ THE SOFTWARE. --> org.jenkins-ci winstone - 5.3 + 5.4-SNAPSHOT test From e09abecb9a942c2ee539a7179bdb1d13f046a3c3 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Mon, 4 Nov 2019 19:56:05 -0500 Subject: [PATCH 02/59] Pluggable handlers. --- .../main/java/jenkins/WebSocketEndpoint.java | 77 ++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/jenkins/WebSocketEndpoint.java b/core/src/main/java/jenkins/WebSocketEndpoint.java index a849294cb16e..c7433e128f06 100644 --- a/core/src/main/java/jenkins/WebSocketEndpoint.java +++ b/core/src/main/java/jenkins/WebSocketEndpoint.java @@ -25,9 +25,13 @@ package jenkins; import hudson.Extension; +import hudson.ExtensionList; +import hudson.ExtensionPoint; import hudson.model.InvisibleAction; import hudson.model.UnprotectedRootAction; import java.lang.reflect.Proxy; +import java.nio.ByteBuffer; +import java.util.concurrent.Future; import javax.servlet.ServletContext; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -56,15 +60,37 @@ private synchronized void init() throws Exception { 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}, (proxy1, method1, args1) -> { + Object servletUpgradeRequest = args1[0]; + String requestPath = (String) servletUpgradeRequest.getClass().getMethod("getRequestPath").invoke(servletUpgradeRequest); + assert requestPath.startsWith("/ws/"); + Session session = ExtensionList.lookup(Service.class).stream().filter(s -> requestPath.substring(4).equals(s.name())).findFirst().get().start(); return Proxy.newProxyInstance(cl, new Class[] {cl.loadClass("org.eclipse.jetty.websocket.api.WebSocketListener")}, (proxy2, method2, args2) -> { - System.err.println("TODO running " + method2.getName()); - return null; + switch (method2.getName()) { + case "onWebSocketConnect": + session.remoteEndpoint = args2[0].getClass().getMethod("getRemote").invoke(args2[0]); + return null; + case "onWebSocketClose": + session.closed((Integer) args2[0], (String) args2[1]); + return null; + case "onWebSocketError": + session.error((Throwable) args2[0]); + return null; + case "onWebSocketBinary": + session.binary((byte[]) args2[0], (Integer) args2[1], (Integer) args2[2]); + return null; + case "onWebSocketText": + session.text((String) args2[0]); + return null; + default: + throw new AssertionError(); + } }); })); } } public void doDynamic(StaplerRequest req, StaplerResponse rsp) throws Exception { + // TODO check rest of path against registered Service.name init(); if (!((Boolean) webSocketServletFactoryClass.getMethod("isUpgradeRequest", HttpServletRequest.class, HttpServletResponse.class).invoke(factory, req, rsp))) { throw HttpResponses.error(HttpServletResponse.SC_BAD_REQUEST, "only WS connections accepted here"); @@ -75,4 +101,51 @@ public void doDynamic(StaplerRequest req, StaplerResponse rsp) throws Exception // OK! } + public interface Service extends ExtensionPoint { + String name(); + Session start(); + } + + public static abstract class Session { + Object remoteEndpoint; + protected void closed(int statusCode, String reason) {} + protected void error(Throwable cause) {} + protected void binary(byte[] payload, int offset, int len) {} + protected void text(String message) {} + @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); + } + } + // TODO ping/pong + } + + @Extension + public static final class Example implements Service { + @Override + public String name() { + return "example"; + } + @Override + public Session start() { + return new Session() { + @Override + protected void text(String message) { + sendText("hello " + message); + } + }; + } + } + } From 5fed56de27dd95311529593a5460da7f8edf400a Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Wed, 6 Nov 2019 13:10:48 -0500 Subject: [PATCH 03/59] errorWithoutStack --- core/src/main/java/jenkins/WebSocketEndpoint.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/jenkins/WebSocketEndpoint.java b/core/src/main/java/jenkins/WebSocketEndpoint.java index c7433e128f06..f7664b268cfd 100644 --- a/core/src/main/java/jenkins/WebSocketEndpoint.java +++ b/core/src/main/java/jenkins/WebSocketEndpoint.java @@ -93,10 +93,10 @@ public void doDynamic(StaplerRequest req, StaplerResponse rsp) throws Exception // TODO check rest of path against registered Service.name init(); if (!((Boolean) webSocketServletFactoryClass.getMethod("isUpgradeRequest", HttpServletRequest.class, HttpServletResponse.class).invoke(factory, req, rsp))) { - throw HttpResponses.error(HttpServletResponse.SC_BAD_REQUEST, "only WS connections accepted here"); + throw HttpResponses.errorWithoutStack(HttpServletResponse.SC_BAD_REQUEST, "only WS connections accepted here"); } if (!((Boolean) webSocketServletFactoryClass.getMethod("acceptWebSocket", HttpServletRequest.class, HttpServletResponse.class).invoke(factory, req, rsp))) { - throw HttpResponses.error(HttpServletResponse.SC_BAD_REQUEST, "did not manage to upgrade"); + throw HttpResponses.errorWithoutStack(HttpServletResponse.SC_BAD_REQUEST, "did not manage to upgrade"); } // OK! } From bc3252deb495634bb53140de897af06c5e186e86 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Thu, 7 Nov 2019 09:00:03 -0500 Subject: [PATCH 04/59] Server-side keepalive pings. --- .../main/java/jenkins/WebSocketEndpoint.java | 49 +++++++++++++++++-- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/jenkins/WebSocketEndpoint.java b/core/src/main/java/jenkins/WebSocketEndpoint.java index f7664b268cfd..cd49618b404f 100644 --- a/core/src/main/java/jenkins/WebSocketEndpoint.java +++ b/core/src/main/java/jenkins/WebSocketEndpoint.java @@ -32,9 +32,12 @@ import java.lang.reflect.Proxy; import java.nio.ByteBuffer; import java.util.concurrent.Future; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; import javax.servlet.ServletContext; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import jenkins.util.Timer; import org.kohsuke.stapler.HttpResponses; import org.kohsuke.stapler.Stapler; import org.kohsuke.stapler.StaplerRequest; @@ -63,14 +66,15 @@ private synchronized void init() throws Exception { Object servletUpgradeRequest = args1[0]; String requestPath = (String) servletUpgradeRequest.getClass().getMethod("getRequestPath").invoke(servletUpgradeRequest); assert requestPath.startsWith("/ws/"); - Session session = ExtensionList.lookup(Service.class).stream().filter(s -> requestPath.substring(4).equals(s.name())).findFirst().get().start(); + Service service = ExtensionList.lookup(Service.class).stream().filter(s -> requestPath.substring(4).equals(s.name())).findFirst().get(); + Session session = service.start(); return Proxy.newProxyInstance(cl, new Class[] {cl.loadClass("org.eclipse.jetty.websocket.api.WebSocketListener")}, (proxy2, method2, args2) -> { switch (method2.getName()) { case "onWebSocketConnect": - session.remoteEndpoint = args2[0].getClass().getMethod("getRemote").invoke(args2[0]); + session.started(args2[0].getClass().getMethod("getRemote").invoke(args2[0]), service.serverKeepAlive()); return null; case "onWebSocketClose": - session.closed((Integer) args2[0], (String) args2[1]); + session._closed((Integer) args2[0], (String) args2[1]); return null; case "onWebSocketError": session.error((Throwable) args2[0]); @@ -104,10 +108,42 @@ public void doDynamic(StaplerRequest req, StaplerResponse rsp) throws Exception public interface Service extends ExtensionPoint { String name(); Session start(); + default boolean serverKeepAlive() { + return false; + } } + /** + * Number of seconds between server-sent pings, if enabled. + * 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. + */ + private static final long PING_INTERVAL_SECONDS = 30; public static abstract class Session { - Object remoteEndpoint; + private Object remoteEndpoint; + private ScheduledFuture pings; + void started(Object remoteEndpoint, boolean keepAlive) { + this.remoteEndpoint = remoteEndpoint; + if (keepAlive) { + pings = Timer.get().scheduleAtFixedRate(() -> { + try { + remoteEndpoint.getClass().getMethod("sendPing", ByteBuffer.class).invoke(remoteEndpoint, ByteBuffer.wrap(new byte[0])); + System.err.println("TODO sent ping"); + } catch (Exception x) { + error(x); + pings.cancel(true); + } + }, PING_INTERVAL_SECONDS / 2, PING_INTERVAL_SECONDS, TimeUnit.SECONDS); + } + } + void _closed(int statusCode, String reason) { + if (pings != null) { + pings.cancel(true); + // alternately, check Session.isOpen each time + } + closed(statusCode, reason); + } protected void closed(int statusCode, String reason) {} protected void error(Throwable cause) {} protected void binary(byte[] payload, int offset, int len) {} @@ -128,7 +164,6 @@ protected final Future sendText(String text) { throw new RuntimeException(x); } } - // TODO ping/pong } @Extension @@ -138,6 +173,10 @@ public String name() { return "example"; } @Override + public boolean serverKeepAlive() { + return true; + } + @Override public Session start() { return new Session() { @Override From 3484ec87f21ea4c2154b9a6e3fc06e97bd17b7d0 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Thu, 7 Nov 2019 17:49:50 -0500 Subject: [PATCH 05/59] Redesigned to work as an HttpResponse. --- ...WebSocketEndpoint.java => WebSockets.java} | 184 +++++++++--------- 1 file changed, 97 insertions(+), 87 deletions(-) rename core/src/main/java/jenkins/{WebSocketEndpoint.java => WebSockets.java} (50%) diff --git a/core/src/main/java/jenkins/WebSocketEndpoint.java b/core/src/main/java/jenkins/WebSockets.java similarity index 50% rename from core/src/main/java/jenkins/WebSocketEndpoint.java rename to core/src/main/java/jenkins/WebSockets.java index cd49618b404f..064b90fca9e8 100644 --- a/core/src/main/java/jenkins/WebSocketEndpoint.java +++ b/core/src/main/java/jenkins/WebSockets.java @@ -26,91 +26,82 @@ import hudson.Extension; import hudson.ExtensionList; -import hudson.ExtensionPoint; import hudson.model.InvisibleAction; import hudson.model.UnprotectedRootAction; +import java.lang.reflect.Method; import java.lang.reflect.Proxy; 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 javax.servlet.ServletContext; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import jenkins.util.Timer; +import org.kohsuke.stapler.HttpResponse; import org.kohsuke.stapler.HttpResponses; import org.kohsuke.stapler.Stapler; -import org.kohsuke.stapler.StaplerRequest; -import org.kohsuke.stapler.StaplerResponse; @Extension -public final class WebSocketEndpoint extends InvisibleAction implements UnprotectedRootAction { +public final class WebSockets { - private Class webSocketServletFactoryClass; - private /*WebSocketServletFactory*/Object factory; + private static final Logger LOGGER = Logger.getLogger(WebSockets.class.getName()); + + private static final String ATTR_SESSION = WebSockets.class.getName() + ".session"; + + // TODO method to see if WS are supported in this container - @Override - public String getUrlName() { - return "ws"; + public static HttpResponse upgrade(Session 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 synchronized void init() throws Exception { - if (factory == null) { - ClassLoader cl = ServletContext.class.getClassLoader(); + 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"); + } + } + + 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}, (proxy1, method1, args1) -> { - Object servletUpgradeRequest = args1[0]; - String requestPath = (String) servletUpgradeRequest.getClass().getMethod("getRequestPath").invoke(servletUpgradeRequest); - assert requestPath.startsWith("/ws/"); - Service service = ExtensionList.lookup(Service.class).stream().filter(s -> requestPath.substring(4).equals(s.name())).findFirst().get(); - Session session = service.start(); - return Proxy.newProxyInstance(cl, new Class[] {cl.loadClass("org.eclipse.jetty.websocket.api.WebSocketListener")}, (proxy2, method2, args2) -> { - switch (method2.getName()) { - case "onWebSocketConnect": - session.started(args2[0].getClass().getMethod("getRemote").invoke(args2[0]), service.serverKeepAlive()); - return null; - case "onWebSocketClose": - session._closed((Integer) args2[0], (String) args2[1]); - return null; - case "onWebSocketError": - session.error((Throwable) args2[0]); - return null; - case "onWebSocketBinary": - session.binary((byte[]) args2[0], (Integer) args2[1], (Integer) args2[2]); - return null; - case "onWebSocketText": - session.text((String) args2[0]); - return null; - default: - throw new AssertionError(); - } - }); - })); - } - } - - public void doDynamic(StaplerRequest req, StaplerResponse rsp) throws Exception { - // TODO check rest of path against registered Service.name - 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"); - } - 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"); + webSocketServletFactoryClass.getMethod("setCreator", webSocketCreatorClass).invoke(factory, Proxy.newProxyInstance(cl, new Class[] {webSocketCreatorClass}, this::createWebSocket)); } - // OK! + return factory; } - public interface Service extends ExtensionPoint { - String name(); - Session start(); - default boolean serverKeepAlive() { - return false; - } + private Object createWebSocket(Object proxy, Method method, Object[] args) throws Exception { + Object servletUpgradeRequest = args[0]; + Session session = (Session) 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); } /** @@ -123,26 +114,43 @@ default boolean serverKeepAlive() { public static abstract class Session { private Object remoteEndpoint; private ScheduledFuture pings; - void started(Object remoteEndpoint, boolean keepAlive) { - this.remoteEndpoint = remoteEndpoint; - if (keepAlive) { - pings = Timer.get().scheduleAtFixedRate(() -> { - try { - remoteEndpoint.getClass().getMethod("sendPing", ByteBuffer.class).invoke(remoteEndpoint, ByteBuffer.wrap(new byte[0])); - System.err.println("TODO sent ping"); - } catch (Exception x) { - error(x); - pings.cancel(true); - } - }, PING_INTERVAL_SECONDS / 2, PING_INTERVAL_SECONDS, TimeUnit.SECONDS); + Object onWebSocketSomething(Object proxy, Method method, Object[] args) throws Exception { + switch (method.getName()) { + case "onWebSocketConnect": + this.remoteEndpoint = args[0].getClass().getMethod("getRemote").invoke(args[0]); + if (keepAlive()) { + 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); + } + 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(); } } - void _closed(int statusCode, String reason) { - if (pings != null) { - pings.cancel(true); - // alternately, check Session.isOpen each time - } - closed(statusCode, reason); + protected boolean keepAlive() { + return false; } protected void closed(int statusCode, String reason) {} protected void error(Throwable cause) {} @@ -167,24 +175,26 @@ protected final Future sendText(String text) { } @Extension - public static final class Example implements Service { - @Override - public String name() { - return "example"; - } + public static final class EchoExample extends InvisibleAction implements UnprotectedRootAction { + @Override - public boolean serverKeepAlive() { - return true; + public String getUrlName() { + return "wsecho"; } - @Override - public Session start() { - return new Session() { + + public HttpResponse doIndex() { + return upgrade(new Session() { + @Override + protected boolean keepAlive() { + return true; + } @Override protected void text(String message) { sendText("hello " + message); } - }; + }); } + } } From 26e62a02cb4089c6f5f2f357061b3408832fa1c2 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Thu, 7 Nov 2019 17:50:57 -0500 Subject: [PATCH 06/59] Unused dep. --- core/pom.xml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/core/pom.xml b/core/pom.xml index b8997ddcd17f..0402553742ec 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -284,12 +284,6 @@ THE SOFTWARE. 3.1.0 provided - commons-io commons-io From f20399887e84e9e6759c659ce4ba1ef6b8b4a5c4 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Fri, 8 Nov 2019 17:23:00 -0500 Subject: [PATCH 07/59] Comment on JnlpSlaveRestarterInstaller. --- .../jenkins/slaves/restarter/JnlpSlaveRestarterInstaller.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/jenkins/slaves/restarter/JnlpSlaveRestarterInstaller.java b/core/src/main/java/jenkins/slaves/restarter/JnlpSlaveRestarterInstaller.java index 8028626981eb..dd9144db1587 100644 --- a/core/src/main/java/jenkins/slaves/restarter/JnlpSlaveRestarterInstaller.java +++ b/core/src/main/java/jenkins/slaves/restarter/JnlpSlaveRestarterInstaller.java @@ -27,7 +27,7 @@ *

* Use {@link ComputerListener} to install {@link EngineListener}, which in turn gets executed when * the agent gets disconnected. - * + *

Despite the name, this is not specific to “JNLP” (TCP) agents, just inbound ones. * @author Kohsuke Kawaguchi */ @Extension From 395521615aaa5381ee84d63d3b8b5ca0cf474154 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Tue, 12 Nov 2019 22:37:30 -0500 Subject: [PATCH 08/59] Sketch of WebSocket-based endpoint for JNLPLauncher. --- core/src/main/java/jenkins/WebSockets.java | 18 ++- .../java/jenkins/slaves/WebSocketAgents.java | 134 ++++++++++++++++++ pom.xml | 2 +- test-pom/pom.xml | 4 +- .../jenkins/slaves/WebSocketAgentsTest.java | 78 ++++++++++ 5 files changed, 232 insertions(+), 4 deletions(-) create mode 100644 core/src/main/java/jenkins/slaves/WebSocketAgents.java create mode 100644 test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java diff --git a/core/src/main/java/jenkins/WebSockets.java b/core/src/main/java/jenkins/WebSockets.java index 064b90fca9e8..1863ffd2174f 100644 --- a/core/src/main/java/jenkins/WebSockets.java +++ b/core/src/main/java/jenkins/WebSockets.java @@ -128,6 +128,7 @@ Object onWebSocketSomething(Object proxy, Method method, Object[] args) throws E } }, PING_INTERVAL_SECONDS / 2, PING_INTERVAL_SECONDS, TimeUnit.SECONDS); } + opened(); return null; case "onWebSocketClose": if (pings != null) { @@ -152,8 +153,11 @@ Object onWebSocketSomething(Object proxy, Method method, Object[] args) throws E protected boolean keepAlive() { return false; } + protected void opened() {} protected void closed(int statusCode, String reason) {} - protected void error(Throwable cause) {} + protected void error(Throwable cause) { + LOGGER.log(Level.WARNING, "unhandled WebSocket service error", cause); + } protected void binary(byte[] payload, int offset, int len) {} protected void text(String message) {} @SuppressWarnings("unchecked") @@ -192,6 +196,18 @@ protected boolean keepAlive() { 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/slaves/WebSocketAgents.java b/core/src/main/java/jenkins/slaves/WebSocketAgents.java new file mode 100644 index 000000000000..5c0bb07ee3f1 --- /dev/null +++ b/core/src/main/java/jenkins/slaves/WebSocketAgents.java @@ -0,0 +1,134 @@ +/* + * 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.slaves; + +import hudson.Extension; +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.slaves.JNLPLauncher; +import hudson.slaves.SlaveComputer; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.logging.Logger; +import jenkins.WebSockets; +import jenkins.model.Jenkins; +import org.kohsuke.stapler.HttpResponse; +import org.kohsuke.stapler.HttpResponses; +import org.kohsuke.stapler.StaplerRequest; +import org.kohsuke.stapler.StaplerResponse; + +@Extension +public final class WebSocketAgents extends InvisibleAction implements UnprotectedRootAction { + + private static final Logger LOGGER = Logger.getLogger(WebSocketAgents.class.getName()); + + @Override + public String getUrlName() { + return "wsagents"; + } + + public HttpResponse doDynamic(StaplerRequest req, StaplerResponse rsp) { + String agent = req.getRestOfPath().substring(1); + Computer c = Jenkins.get().getComputer(agent); + if (!(c instanceof SlaveComputer)) { + throw HttpResponses.notFound(); + } + SlaveComputer sc = (SlaveComputer) c; + if (!(sc.getLauncher() instanceof JNLPLauncher)) { + throw HttpResponses.errorWithoutStack(400, "not an inbound agent"); + } + String secret = req.getParameter("secret"); + if (!MessageDigest.isEqual(secret.getBytes(StandardCharsets.US_ASCII), sc.getJnlpMac().getBytes(StandardCharsets.US_ASCII))) { + throw HttpResponses.forbidden(); // TODO unless anonymous has CONNECT? + } + LOGGER.fine(() -> "connecting " + agent); + return WebSockets.upgrade(new WebSockets.Session() { + AbstractByteArrayCommandTransport.ByteArrayReceiver receiver; + @Override + protected void opened() { + Computer.threadPoolForRemoting.submit(() -> { + sc.setChannel(new ChannelBuilder(agent, Computer.threadPoolForRemoting).withHeaderStream(sc.openLogFile()), new 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 new Capability(); // TODO figure out how to negotiate + } + @Override + public void closeWrite() throws IOException { + LOGGER.finest(() -> "closeWrite"); + // TODO + } + @Override + public void closeRead() throws IOException { + LOGGER.finest(() -> "closeRead"); + // TODO + } + }, null); + LOGGER.fine(() -> "set up channel for " + agent); + return null; + }); + } + @Override + protected void closed(int statusCode, String reason) { + LOGGER.finest(() -> "closed " + statusCode + " " + reason); + // TODO + } + @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 boolean keepAlive() { + return true; // Remoting ping thread may be too slow + } + }); + } + +} diff --git a/pom.xml b/pom.xml index a50e748c3e14..e35ba302069a 100755 --- a/pom.xml +++ b/pom.xml @@ -102,7 +102,7 @@ THE SOFTWARE. 3.2.3 - 3.36 + 3.37-SNAPSHOT 3.14 diff --git a/test-pom/pom.xml b/test-pom/pom.xml index 832cd72e4820..835a8065082f 100644 --- a/test-pom/pom.xml +++ b/test-pom/pom.xml @@ -71,7 +71,7 @@ THE SOFTWARE. ${project.groupId} jenkins-test-harness - 2.55 + 2.57-SNAPSHOT test @@ -167,7 +167,7 @@ THE SOFTWARE. org.hamcrest hamcrest-core - 1.3 + 2.2 diff --git a/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java b/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java new file mode 100644 index 000000000000..a9993ae94ba9 --- /dev/null +++ b/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java @@ -0,0 +1,78 @@ +/* + * 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.slaves; + +import hudson.model.Computer; +import hudson.model.FreeStyleProject; +import hudson.remoting.Engine; +import hudson.slaves.DumbSlave; +import hudson.slaves.JNLPLauncher; +import hudson.slaves.SlaveComputer; +import hudson.tasks.Shell; +import java.util.logging.Level; +import jenkins.security.SlaveToMasterCallable; +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.JenkinsRule; +import org.jvnet.hudson.test.LoggerRule; + +public class WebSocketAgentsTest { + + @ClassRule public static BuildWatcher buildWatcher = new BuildWatcher(); + + @Rule public JenkinsRule r = new JenkinsRule(); + + @Rule public LoggerRule logging = new LoggerRule().record(SlaveComputer.class, Level.FINEST).record(WebSocketAgents.class, Level.FINEST).record(Engine.class, Level.FINEST); + + @Rule public TemporaryFolder tmp = new TemporaryFolder(); + + @Test public void smokes() throws Exception { + DumbSlave s = new DumbSlave("remote", tmp.newFolder("agent").getAbsolutePath(), new JNLPLauncher(true)); + r.jenkins.addNode(s); + String secret = ((SlaveComputer) r.jenkins.getComputer("remote")).getJnlpMac(); + Computer.threadPoolForRemoting.submit(() -> { + // Not as realistic class loading as JNLPLauncherTest.testHeadlessLaunch, but faster to iterate since everything runs inside one JVM. + hudson.remoting.jnlp.Main._main(new String[] {"-headless", "-url", r.getURL().toString(), "-workDir", tmp.newFolder("work").getAbsolutePath(), secret, "remote"}); + return null; + }); + r.waitOnline(s); + assertEquals("response", s.getChannel().call(new DummyTask())); + FreeStyleProject p = r.createFreeStyleProject(); + p.setAssignedNode(s); + p.getBuildersList().add(new Shell("echo hello")); // TODO Windows equivalent + r.buildAndAssertSuccess(p); + } + + private static class DummyTask extends SlaveToMasterCallable { + @Override public String call() { + return "response"; + } + } + +} From 78294aeda24690254c53e820763c746ffd09ae2c Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Tue, 12 Nov 2019 22:54:20 -0500 Subject: [PATCH 09/59] Comment. --- core/src/main/java/hudson/slaves/JNLPLauncher.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/src/main/java/hudson/slaves/JNLPLauncher.java b/core/src/main/java/hudson/slaves/JNLPLauncher.java index 86f38d6b948d..4c9f3403f866 100644 --- a/core/src/main/java/hudson/slaves/JNLPLauncher.java +++ b/core/src/main/java/hudson/slaves/JNLPLauncher.java @@ -206,6 +206,8 @@ public boolean isWorkDirSupported() { @Extension public static class DescriptorVisibilityFilterImpl extends DescriptorVisibilityFilter { + // TODO allow it when WebSocket is available + @Override public boolean filter(@CheckForNull Object context, @Nonnull Descriptor descriptor) { return descriptor.clazz != JNLPLauncher.class || Jenkins.get().getTcpSlaveAgentListener() != null; From 38c663cbe7d6c18dea9f826d92435146ea74f58d Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Wed, 20 Nov 2019 16:42:42 -0500 Subject: [PATCH 10/59] Test enhancement. --- test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java b/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java index a9993ae94ba9..7bbf99775333 100644 --- a/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java +++ b/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java @@ -24,12 +24,14 @@ package jenkins.slaves; +import hudson.Functions; import hudson.model.Computer; import hudson.model.FreeStyleProject; 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.util.logging.Level; import jenkins.security.SlaveToMasterCallable; @@ -65,7 +67,7 @@ public class WebSocketAgentsTest { assertEquals("response", s.getChannel().call(new DummyTask())); FreeStyleProject p = r.createFreeStyleProject(); p.setAssignedNode(s); - p.getBuildersList().add(new Shell("echo hello")); // TODO Windows equivalent + p.getBuildersList().add(Functions.isWindows() ? new BatchFile("echo hello") : new Shell("echo hello")); r.buildAndAssertSuccess(p); } From 7754da51699e7a43321f92abebee413af52e9d5e Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Wed, 20 Nov 2019 19:00:38 -0500 Subject: [PATCH 11/59] Making it more obvious from JNLPLauncherTest where remoting.jar is being loaded from. --- test/src/test/java/hudson/slaves/JNLPLauncherTest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/src/test/java/hudson/slaves/JNLPLauncherTest.java b/test/src/test/java/hudson/slaves/JNLPLauncherTest.java index c76303719f7e..b681968cb148 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. From 61a8f11dac6a6334886095537880387a05e1d70e Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Wed, 20 Nov 2019 19:11:55 -0500 Subject: [PATCH 12/59] Reworked protocol to negotiate remote capabilities. --- .../java/jenkins/slaves/WebSocketAgents.java | 156 +++++++++++------- 1 file changed, 97 insertions(+), 59 deletions(-) diff --git a/core/src/main/java/jenkins/slaves/WebSocketAgents.java b/core/src/main/java/jenkins/slaves/WebSocketAgents.java index 5c0bb07ee3f1..17a0ca892be2 100644 --- a/core/src/main/java/jenkins/slaves/WebSocketAgents.java +++ b/core/src/main/java/jenkins/slaves/WebSocketAgents.java @@ -21,6 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ + package jenkins.slaves; import hudson.Extension; @@ -33,18 +34,18 @@ import hudson.remoting.ChannelBuilder; import hudson.slaves.JNLPLauncher; import hudson.slaves.SlaveComputer; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.Arrays; +import java.util.logging.Level; import java.util.logging.Logger; import jenkins.WebSockets; import jenkins.model.Jenkins; import org.kohsuke.stapler.HttpResponse; -import org.kohsuke.stapler.HttpResponses; -import org.kohsuke.stapler.StaplerRequest; -import org.kohsuke.stapler.StaplerResponse; @Extension public final class WebSocketAgents extends InvisibleAction implements UnprotectedRootAction { @@ -56,59 +57,71 @@ public String getUrlName() { return "wsagents"; } - public HttpResponse doDynamic(StaplerRequest req, StaplerResponse rsp) { - String agent = req.getRestOfPath().substring(1); - Computer c = Jenkins.get().getComputer(agent); - if (!(c instanceof SlaveComputer)) { - throw HttpResponses.notFound(); - } - SlaveComputer sc = (SlaveComputer) c; - if (!(sc.getLauncher() instanceof JNLPLauncher)) { - throw HttpResponses.errorWithoutStack(400, "not an inbound agent"); - } - String secret = req.getParameter("secret"); - if (!MessageDigest.isEqual(secret.getBytes(StandardCharsets.US_ASCII), sc.getJnlpMac().getBytes(StandardCharsets.US_ASCII))) { - throw HttpResponses.forbidden(); // TODO unless anonymous has CONNECT? - } - LOGGER.fine(() -> "connecting " + agent); + public HttpResponse doIndex() { + LOGGER.fine("connecting"); return WebSockets.upgrade(new WebSockets.Session() { + String agent; + String secret; + Capability remoteCapability; AbstractByteArrayCommandTransport.ByteArrayReceiver receiver; + // Expect to receive agent, then secret, then remoteCapability; then will send a capability; then channel is started. @Override - protected void opened() { - Computer.threadPoolForRemoting.submit(() -> { - sc.setChannel(new ChannelBuilder(agent, Computer.threadPoolForRemoting).withHeaderStream(sc.openLogFile()), new 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 new Capability(); // TODO figure out how to negotiate - } - @Override - public void closeWrite() throws IOException { - LOGGER.finest(() -> "closeWrite"); - // TODO - } - @Override - public void closeRead() throws IOException { - LOGGER.finest(() -> "closeRead"); - // TODO + protected void text(String message) { + if (agent == null) { + agent = message; + } else if (secret == null) { + secret = message; + } else { + LOGGER.warning("unexpected text frame"); + } + } + @Override + protected void binary(byte[] payload, int offset, int len) { + if (remoteCapability == null) { + if (agent == null || secret == null) { + LOGGER.warning("unexpected binary frame"); + return; // TODO close connection + } + try (ByteArrayInputStream bais = new ByteArrayInputStream(payload, offset, len)) { + remoteCapability = Capability.read(bais); + LOGGER.fine(() -> "received " + remoteCapability); + } catch (IOException x) { + LOGGER.log(Level.WARNING, "could not read remote capability", x); + return; // TODO close connection + } + Computer c = Jenkins.get().getComputer(agent); + if (!(c instanceof SlaveComputer)) { + LOGGER.warning("no such agent " + agent); + return; // TODO close connection + } + SlaveComputer sc = (SlaveComputer) c; + if (!(sc.getLauncher() instanceof JNLPLauncher)) { + LOGGER.warning(agent + " is not inbound"); + return; // TODO close connection + } + if (!MessageDigest.isEqual(secret.getBytes(StandardCharsets.US_ASCII), sc.getJnlpMac().getBytes(StandardCharsets.US_ASCII))) { // TODO unless anonymous has CONNECT? + LOGGER.warning("incorrect secret"); + return; // TODO close connection + } + Computer.threadPoolForRemoting.submit(() -> { + LOGGER.fine(() -> "sending capabilities for " + agent); + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + new Capability().write(baos); + sendBinary(ByteBuffer.wrap(baos.toByteArray())); } - }, null); - LOGGER.fine(() -> "set up channel for " + agent); - return null; - }); + LOGGER.fine(() -> "setting up channel for " + agent); + sc.setChannel(new ChannelBuilder(agent, Computer.threadPoolForRemoting).withHeaderStream(sc.openLogFile()), new Transport(), null); + LOGGER.fine(() -> "set up channel for " + agent); + return null; + }); + } else { + 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) { @@ -116,18 +129,43 @@ protected void closed(int statusCode, String reason) { // TODO } @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)); - } + protected void error(Throwable cause) { + LOGGER.log(Level.WARNING, null, cause); } @Override protected boolean keepAlive() { return true; // Remoting ping thread may be too slow } + 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"); + // TODO + } + @Override + public void closeRead() throws IOException { + LOGGER.finest(() -> "closeRead"); + // TODO + } + } }); } From 36c456d944080865d08001d20378a1b77db9a22b Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Wed, 20 Nov 2019 19:42:46 -0500 Subject: [PATCH 13/59] Unhelpful comment. --- .../jenkins/slaves/restarter/JnlpSlaveRestarterInstaller.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/jenkins/slaves/restarter/JnlpSlaveRestarterInstaller.java b/core/src/main/java/jenkins/slaves/restarter/JnlpSlaveRestarterInstaller.java index dd9144db1587..8028626981eb 100644 --- a/core/src/main/java/jenkins/slaves/restarter/JnlpSlaveRestarterInstaller.java +++ b/core/src/main/java/jenkins/slaves/restarter/JnlpSlaveRestarterInstaller.java @@ -27,7 +27,7 @@ *

* Use {@link ComputerListener} to install {@link EngineListener}, which in turn gets executed when * the agent gets disconnected. - *

Despite the name, this is not specific to “JNLP” (TCP) agents, just inbound ones. + * * @author Kohsuke Kawaguchi */ @Extension From 3e9258b3c3d3689ad1dd58fbf259fe35bbff4208 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Thu, 21 Nov 2019 14:46:43 -0500 Subject: [PATCH 14/59] Simplifying handshake to use HTTP headers. --- .../java/jenkins/slaves/WebSocketAgents.java | 100 ++++++++---------- 1 file changed, 43 insertions(+), 57 deletions(-) diff --git a/core/src/main/java/jenkins/slaves/WebSocketAgents.java b/core/src/main/java/jenkins/slaves/WebSocketAgents.java index 17a0ca892be2..9af208892dfb 100644 --- a/core/src/main/java/jenkins/slaves/WebSocketAgents.java +++ b/core/src/main/java/jenkins/slaves/WebSocketAgents.java @@ -45,11 +45,17 @@ import java.util.logging.Logger; import jenkins.WebSockets; import jenkins.model.Jenkins; +import org.jenkinsci.remoting.engine.JnlpConnectionState; +import org.kohsuke.stapler.Header; import org.kohsuke.stapler.HttpResponse; +import org.kohsuke.stapler.HttpResponses; +import org.kohsuke.stapler.StaplerResponse; @Extension public final class WebSocketAgents extends InvisibleAction implements UnprotectedRootAction { + private static final String CAPABILITY_KEY = /* Capability.class.getName() */"hudson.remoting.Capability"; + private static final Logger LOGGER = Logger.getLogger(WebSocketAgents.class.getName()); @Override @@ -57,70 +63,50 @@ public String getUrlName() { return "wsagents"; } - public HttpResponse doIndex() { - LOGGER.fine("connecting"); + public HttpResponse doIndex( + @Header(value = JnlpConnectionState.CLIENT_NAME_KEY, required = true) String agent, + @Header(value = JnlpConnectionState.SECRET_KEY, required = true) String secret, + @Header(value = CAPABILITY_KEY, required = true) String remoteCapabilityStr, + StaplerResponse rsp) throws IOException { + Computer c = Jenkins.get().getComputer(agent); + if (!(c instanceof SlaveComputer)) { + throw HttpResponses.notFound(); + } + SlaveComputer sc = (SlaveComputer) c; + if (!(sc.getLauncher() instanceof JNLPLauncher)) { + throw HttpResponses.errorWithoutStack(400, "not an inbound agent"); + } + if (!MessageDigest.isEqual(secret.getBytes(StandardCharsets.US_ASCII), sc.getJnlpMac().getBytes(StandardCharsets.US_ASCII))) { // TODO unless anonymous has CONNECT? + throw HttpResponses.forbidden(); + } + LOGGER.fine(() -> "connecting " + agent); + Capability remoteCapability; + try (ByteArrayInputStream bais = new ByteArrayInputStream(remoteCapabilityStr.getBytes(StandardCharsets.US_ASCII))) { + remoteCapability = Capability.read(bais); + LOGGER.fine(() -> "received " + remoteCapability); + } + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + new Capability().write(baos); + rsp.setHeader(CAPABILITY_KEY, baos.toString("US-ASCII")); + } return WebSockets.upgrade(new WebSockets.Session() { - String agent; - String secret; - Capability remoteCapability; AbstractByteArrayCommandTransport.ByteArrayReceiver receiver; - // Expect to receive agent, then secret, then remoteCapability; then will send a capability; then channel is started. @Override - protected void text(String message) { - if (agent == null) { - agent = message; - } else if (secret == null) { - secret = message; - } else { - LOGGER.warning("unexpected text frame"); - } + protected void opened() { + Computer.threadPoolForRemoting.submit(() -> { + LOGGER.fine(() -> "setting up channel for " + agent); + sc.setChannel(new ChannelBuilder(agent, Computer.threadPoolForRemoting).withHeaderStream(sc.openLogFile()), new Transport(), null); + LOGGER.fine(() -> "set up channel for " + agent); + return null; + }); } @Override protected void binary(byte[] payload, int offset, int len) { - if (remoteCapability == null) { - if (agent == null || secret == null) { - LOGGER.warning("unexpected binary frame"); - return; // TODO close connection - } - try (ByteArrayInputStream bais = new ByteArrayInputStream(payload, offset, len)) { - remoteCapability = Capability.read(bais); - LOGGER.fine(() -> "received " + remoteCapability); - } catch (IOException x) { - LOGGER.log(Level.WARNING, "could not read remote capability", x); - return; // TODO close connection - } - Computer c = Jenkins.get().getComputer(agent); - if (!(c instanceof SlaveComputer)) { - LOGGER.warning("no such agent " + agent); - return; // TODO close connection - } - SlaveComputer sc = (SlaveComputer) c; - if (!(sc.getLauncher() instanceof JNLPLauncher)) { - LOGGER.warning(agent + " is not inbound"); - return; // TODO close connection - } - if (!MessageDigest.isEqual(secret.getBytes(StandardCharsets.US_ASCII), sc.getJnlpMac().getBytes(StandardCharsets.US_ASCII))) { // TODO unless anonymous has CONNECT? - LOGGER.warning("incorrect secret"); - return; // TODO close connection - } - Computer.threadPoolForRemoting.submit(() -> { - LOGGER.fine(() -> "sending capabilities for " + agent); - try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { - new Capability().write(baos); - sendBinary(ByteBuffer.wrap(baos.toByteArray())); - } - LOGGER.fine(() -> "setting up channel for " + agent); - sc.setChannel(new ChannelBuilder(agent, Computer.threadPoolForRemoting).withHeaderStream(sc.openLogFile()), new Transport(), null); - LOGGER.fine(() -> "set up channel for " + agent); - return null; - }); + LOGGER.finest(() -> "reading block of length " + len + " from " + agent); + if (offset == 0 && len == payload.length) { + receiver.handle(payload); } else { - 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)); - } + receiver.handle(Arrays.copyOfRange(payload, offset, offset + len)); } } @Override From 3f2b6cf148c405776fb262f40decbb28efef3f1d Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Thu, 21 Nov 2019 16:09:16 -0500 Subject: [PATCH 15/59] Moving code into top-level classes and otherwise prettifying. --- core/src/main/java/jenkins/WebSockets.java | 216 ------------------ .../java/jenkins/slaves/WebSocketAgents.java | 137 ++++++----- .../java/jenkins/websocket/WebSocketEcho.java | 69 ++++++ .../jenkins/websocket/WebSocketSession.java | 135 +++++++++++ .../java/jenkins/websocket/WebSockets.java | 107 +++++++++ .../jenkins/slaves/WebSocketAgentsTest.java | 18 +- 6 files changed, 406 insertions(+), 276 deletions(-) delete mode 100644 core/src/main/java/jenkins/WebSockets.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 diff --git a/core/src/main/java/jenkins/WebSockets.java b/core/src/main/java/jenkins/WebSockets.java deleted file mode 100644 index 1863ffd2174f..000000000000 --- a/core/src/main/java/jenkins/WebSockets.java +++ /dev/null @@ -1,216 +0,0 @@ -/* - * 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; - -import hudson.Extension; -import hudson.ExtensionList; -import hudson.model.InvisibleAction; -import hudson.model.UnprotectedRootAction; -import java.lang.reflect.Method; -import java.lang.reflect.Proxy; -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 javax.servlet.ServletContext; -import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; -import jenkins.util.Timer; -import org.kohsuke.stapler.HttpResponse; -import org.kohsuke.stapler.HttpResponses; -import org.kohsuke.stapler.Stapler; - -@Extension -public final class WebSockets { - - private static final Logger LOGGER = Logger.getLogger(WebSockets.class.getName()); - - private static final String ATTR_SESSION = WebSockets.class.getName() + ".session"; - - // TODO method to see if WS are supported in this container - - public static HttpResponse upgrade(Session 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"); - } - } - - 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]; - Session session = (Session) 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); - } - - /** - * Number of seconds between server-sent pings, if enabled. - * 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. - */ - private static final long PING_INTERVAL_SECONDS = 30; - public static abstract class Session { - private Object remoteEndpoint; - private ScheduledFuture pings; - Object onWebSocketSomething(Object proxy, Method method, Object[] args) throws Exception { - switch (method.getName()) { - case "onWebSocketConnect": - this.remoteEndpoint = args[0].getClass().getMethod("getRemote").invoke(args[0]); - if (keepAlive()) { - 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 boolean keepAlive() { - return false; - } - 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) {} - protected void text(String message) {} - @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); - } - } - } - - @Extension - public static final class EchoExample extends InvisibleAction implements UnprotectedRootAction { - - @Override - public String getUrlName() { - return "wsecho"; - } - - public HttpResponse doIndex() { - return upgrade(new Session() { - @Override - protected boolean keepAlive() { - return true; - } - @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/slaves/WebSocketAgents.java b/core/src/main/java/jenkins/slaves/WebSocketAgents.java index 9af208892dfb..0a6c55b304da 100644 --- a/core/src/main/java/jenkins/slaves/WebSocketAgents.java +++ b/core/src/main/java/jenkins/slaves/WebSocketAgents.java @@ -43,15 +43,19 @@ import java.util.Arrays; import java.util.logging.Level; import java.util.logging.Logger; -import jenkins.WebSockets; import jenkins.model.Jenkins; +import jenkins.websocket.WebSocketSession; +import jenkins.websocket.WebSockets; import org.jenkinsci.remoting.engine.JnlpConnectionState; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.DoNotUse; import org.kohsuke.stapler.Header; import org.kohsuke.stapler.HttpResponse; import org.kohsuke.stapler.HttpResponses; import org.kohsuke.stapler.StaplerResponse; @Extension +@Restricted(DoNotUse.class) public final class WebSocketAgents extends InvisibleAction implements UnprotectedRootAction { private static final String CAPABILITY_KEY = /* Capability.class.getName() */"hudson.remoting.Capability"; @@ -89,70 +93,95 @@ public HttpResponse doIndex( new Capability().write(baos); rsp.setHeader(CAPABILITY_KEY, baos.toString("US-ASCII")); } - return WebSockets.upgrade(new WebSockets.Session() { - AbstractByteArrayCommandTransport.ByteArrayReceiver receiver; + return WebSockets.upgrade(new Session(agent, sc, remoteCapability)); + } + + private static class Session extends WebSocketSession { + + private final String agent; + private final SlaveComputer sc; + private final Capability remoteCapability; + private AbstractByteArrayCommandTransport.ByteArrayReceiver receiver; + + Session(String agent, SlaveComputer sc, Capability remoteCapability) { + this.agent = agent; + this.sc = sc; + this.remoteCapability = remoteCapability; + } + + @Override + protected void opened() { + Computer.threadPoolForRemoting.submit(() -> { + LOGGER.fine(() -> "setting up channel for " + agent); + sc.setChannel(new ChannelBuilder(agent, Computer.threadPoolForRemoting).withHeaderStream(sc.openLogFile()), new Transport(), null); + 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); + // TODO + } + + @Override + protected void error(Throwable cause) { + LOGGER.log(Level.WARNING, null, cause); + } + + @Override + protected boolean keepAlive() { + return true; // Remoting ping thread may be too slow + } + + class Transport extends AbstractByteArrayCommandTransport { + @Override - protected void opened() { - Computer.threadPoolForRemoting.submit(() -> { - LOGGER.fine(() -> "setting up channel for " + agent); - sc.setChannel(new ChannelBuilder(agent, Computer.threadPoolForRemoting).withHeaderStream(sc.openLogFile()), new Transport(), null); - LOGGER.fine(() -> "set up channel for " + agent); - return null; - }); + public void setup(AbstractByteArrayCommandTransport.ByteArrayReceiver bar) { + receiver = bar; } + @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)); + 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 - protected void closed(int statusCode, String reason) { - LOGGER.finest(() -> "closed " + statusCode + " " + reason); - // TODO + public Capability getRemoteCapability() throws IOException { + return remoteCapability; } + @Override - protected void error(Throwable cause) { - LOGGER.log(Level.WARNING, null, cause); + public void closeWrite() throws IOException { + LOGGER.finest(() -> "closeWrite"); + // TODO } + @Override - protected boolean keepAlive() { - return true; // Remoting ping thread may be too slow - } - 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"); - // TODO - } - @Override - public void closeRead() throws IOException { - LOGGER.finest(() -> "closeRead"); - // TODO - } + public void closeRead() throws IOException { + LOGGER.finest(() -> "closeRead"); + // TODO } - }); + + } + } } 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..c917d60c519e --- /dev/null +++ b/core/src/main/java/jenkins/websocket/WebSocketEcho.java @@ -0,0 +1,69 @@ +/* + * 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.UnprotectedRootAction; +import java.nio.ByteBuffer; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.DoNotUse; +import org.kohsuke.stapler.HttpResponse; + +@Extension +@Restricted(DoNotUse.class) +public class WebSocketEcho extends InvisibleAction implements UnprotectedRootAction { + + @Override + public String getUrlName() { + return "wsecho"; + } + + public HttpResponse doIndex() { + return WebSockets.upgrade(new WebSocketSession() { + @Override + protected boolean keepAlive() { + return true; + } + @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..ca40550f9197 --- /dev/null +++ b/core/src/main/java/jenkins/websocket/WebSocketSession.java @@ -0,0 +1,135 @@ +/* + * 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.Timer; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.Beta; + +/** + * One WebSocket connection. + * @see WebSockets + */ +@Restricted(Beta.class) +public abstract class WebSocketSession { + + /** + * Number of seconds between server-sent pings, if enabled. + * 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. + */ + private static final long PING_INTERVAL_SECONDS = 30; + + private static final Logger LOGGER = Logger.getLogger(WebSocketSession.class.getName()); + + private Object remoteEndpoint; + private ScheduledFuture pings; + + protected WebSocketSession() {} + + Object onWebSocketSomething(Object proxy, Method method, Object[] args) throws Exception { + switch (method.getName()) { + case "onWebSocketConnect": + this.remoteEndpoint = args[0].getClass().getMethod("getRemote").invoke(args[0]); + if (keepAlive()) { + 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 boolean keepAlive() { + return false; + } + + 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) { + } + + protected void text(String message) { + } + + @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); + } + } + +} 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..b3ac0b8bce2d --- /dev/null +++ b/core/src/main/java/jenkins/websocket/WebSockets.java @@ -0,0 +1,107 @@ +/* + * 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. + */ +@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 method to see if WS are supported in this container + // 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"); + } + } + + 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/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java b/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java index 7bbf99775333..b53ba13e71c4 100644 --- a/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java +++ b/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java @@ -46,15 +46,20 @@ public class WebSocketAgentsTest { - @ClassRule public static BuildWatcher buildWatcher = new BuildWatcher(); + @ClassRule + public static BuildWatcher buildWatcher = new BuildWatcher(); - @Rule public JenkinsRule r = new JenkinsRule(); + @Rule + public JenkinsRule r = new JenkinsRule(); - @Rule public LoggerRule logging = new LoggerRule().record(SlaveComputer.class, Level.FINEST).record(WebSocketAgents.class, Level.FINEST).record(Engine.class, Level.FINEST); + @Rule + public LoggerRule logging = new LoggerRule().record(SlaveComputer.class, Level.FINEST).record(WebSocketAgents.class, Level.FINEST).record(Engine.class, Level.FINEST); - @Rule public TemporaryFolder tmp = new TemporaryFolder(); + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); - @Test public void smokes() throws Exception { + @Test + public void smokes() throws Exception { DumbSlave s = new DumbSlave("remote", tmp.newFolder("agent").getAbsolutePath(), new JNLPLauncher(true)); r.jenkins.addNode(s); String secret = ((SlaveComputer) r.jenkins.getComputer("remote")).getJnlpMac(); @@ -72,7 +77,8 @@ public class WebSocketAgentsTest { } private static class DummyTask extends SlaveToMasterCallable { - @Override public String call() { + @Override + public String call() { return "response"; } } From 2f7ef21280662be4e40a35637ce2ad2eb71c3317 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Fri, 22 Nov 2019 16:08:40 -0500 Subject: [PATCH 16/59] Linking to upstream PRs. --- pom.xml | 2 +- test-pom/pom.xml | 2 +- war/pom.xml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pom.xml b/pom.xml index 2c80948e9e78..1e3d82e20c36 100755 --- a/pom.xml +++ b/pom.xml @@ -102,7 +102,7 @@ THE SOFTWARE. 3.2.3 - 3.37-SNAPSHOT + 3.37-SNAPSHOT 3.14 diff --git a/test-pom/pom.xml b/test-pom/pom.xml index 835a8065082f..1a1718d2deae 100644 --- a/test-pom/pom.xml +++ b/test-pom/pom.xml @@ -71,7 +71,7 @@ THE SOFTWARE. ${project.groupId} jenkins-test-harness - 2.57-SNAPSHOT + 2.57-SNAPSHOT test diff --git a/war/pom.xml b/war/pom.xml index 17bcfe3d407e..3ddb41aa4a5d 100644 --- a/war/pom.xml +++ b/war/pom.xml @@ -101,7 +101,7 @@ THE SOFTWARE. --> org.jenkins-ci winstone - 5.5-SNAPSHOT + 5.5-20191122.203955-1 test From 721aae159dcc9e0e11f01a6e418b6c80ace83de5 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Fri, 22 Nov 2019 16:46:35 -0500 Subject: [PATCH 17/59] Timestamped snapshot + incremental. --- pom.xml | 2 +- test-pom/pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 1e3d82e20c36..2e3c45594078 100755 --- a/pom.xml +++ b/pom.xml @@ -102,7 +102,7 @@ THE SOFTWARE. 3.2.3 - 3.37-SNAPSHOT + 3.37-20191122.214314-1 3.14 diff --git a/test-pom/pom.xml b/test-pom/pom.xml index 1a1718d2deae..0dea71c8a7f4 100644 --- a/test-pom/pom.xml +++ b/test-pom/pom.xml @@ -71,7 +71,7 @@ THE SOFTWARE. ${project.groupId} jenkins-test-harness - 2.57-SNAPSHOT + 2.57-rc1258.021b941d1c70 test From 69e50d3311aad5551a4848dcc8544dd7c1ded81f Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Mon, 25 Nov 2019 16:15:19 -0500 Subject: [PATCH 18/59] Picking up incrementalified build of winstone. --- war/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/war/pom.xml b/war/pom.xml index 3ddb41aa4a5d..f54bc091e2e7 100644 --- a/war/pom.xml +++ b/war/pom.xml @@ -101,7 +101,7 @@ THE SOFTWARE. --> org.jenkins-ci winstone - 5.5-20191122.203955-1 + 5.5-rc481.482ea78c534a test From 8487eb4a792641d6a0ec46459b68cb97f727c712 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Mon, 25 Nov 2019 16:17:19 -0500 Subject: [PATCH 19/59] Working around https://github.com/kohsuke/access-modifier/pull/17. --- core/src/main/java/jenkins/slaves/WebSocketAgents.java | 4 ++-- core/src/main/java/jenkins/websocket/WebSocketEcho.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/jenkins/slaves/WebSocketAgents.java b/core/src/main/java/jenkins/slaves/WebSocketAgents.java index 0a6c55b304da..ee48cb1867d7 100644 --- a/core/src/main/java/jenkins/slaves/WebSocketAgents.java +++ b/core/src/main/java/jenkins/slaves/WebSocketAgents.java @@ -48,14 +48,14 @@ import jenkins.websocket.WebSockets; import org.jenkinsci.remoting.engine.JnlpConnectionState; import org.kohsuke.accmod.Restricted; -import org.kohsuke.accmod.restrictions.DoNotUse; +import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.Header; import org.kohsuke.stapler.HttpResponse; import org.kohsuke.stapler.HttpResponses; import org.kohsuke.stapler.StaplerResponse; @Extension -@Restricted(DoNotUse.class) +@Restricted(NoExternalUse.class) public final class WebSocketAgents extends InvisibleAction implements UnprotectedRootAction { private static final String CAPABILITY_KEY = /* Capability.class.getName() */"hudson.remoting.Capability"; diff --git a/core/src/main/java/jenkins/websocket/WebSocketEcho.java b/core/src/main/java/jenkins/websocket/WebSocketEcho.java index c917d60c519e..ebe89c2fbb0b 100644 --- a/core/src/main/java/jenkins/websocket/WebSocketEcho.java +++ b/core/src/main/java/jenkins/websocket/WebSocketEcho.java @@ -29,11 +29,11 @@ import hudson.model.UnprotectedRootAction; import java.nio.ByteBuffer; import org.kohsuke.accmod.Restricted; -import org.kohsuke.accmod.restrictions.DoNotUse; +import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.stapler.HttpResponse; @Extension -@Restricted(DoNotUse.class) +@Restricted(NoExternalUse.class) public class WebSocketEcho extends InvisibleAction implements UnprotectedRootAction { @Override From df54334da3265dfd039f761d945632c4b377649f Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Tue, 26 Nov 2019 11:51:31 -0500 Subject: [PATCH 20/59] Finally have an incrementalified build of remoting. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 773c3b379600..168d6a61b00c 100755 --- a/pom.xml +++ b/pom.xml @@ -102,7 +102,7 @@ THE SOFTWARE. 3.2.3 - 3.37-20191122.214314-1 + 3.37-rc2650.89397b114373 3.14 From ae34a2e331b2b3128d4ab65e6147f7533f2cbdf8 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Mon, 9 Dec 2019 21:11:24 -0500 Subject: [PATCH 21/59] Use -webSocket option. --- .../main/java/hudson/slaves/JNLPLauncher.java | 14 ++++ .../java/jenkins/slaves/WebSocketAgents.java | 18 ++--- .../hudson/slaves/JNLPLauncher/config.jelly | 3 + .../slaves/JNLPLauncher/help-webSocket.html | 4 ++ .../SlaveComputer/slave-agent.jnlp.jelly | 3 + pom.xml | 2 +- .../jenkins/slaves/WebSocketAgentsTest.java | 72 ++++++++++++++++--- 7 files changed, 92 insertions(+), 24 deletions(-) create mode 100644 core/src/main/resources/hudson/slaves/JNLPLauncher/help-webSocket.html diff --git a/core/src/main/java/hudson/slaves/JNLPLauncher.java b/core/src/main/java/hudson/slaves/JNLPLauncher.java index 4c9f3403f866..3169ad39e958 100644 --- a/core/src/main/java/hudson/slaves/JNLPLauncher.java +++ b/core/src/main/java/hudson/slaves/JNLPLauncher.java @@ -72,6 +72,8 @@ public class JNLPLauncher extends ComputerLauncher { @Nonnull private RemotingWorkDirSettings workDirSettings = RemotingWorkDirSettings.getEnabledDefaults(); + private boolean webSocket; + /** * Constructor. * @param tunnel Tunnel settings @@ -143,6 +145,15 @@ public boolean isLaunchSupported() { return false; } + public boolean isWebSocket() { + return webSocket; + } + + @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,6 +207,9 @@ public boolean isWorkDirSupported() { // Causes JENKINS-45895 in the case of includes otherwise return DescriptorImpl.class.equals(getClass()); } + + // TODO form validation: webSocket and tunnel probably mutually exclusive + } /** diff --git a/core/src/main/java/jenkins/slaves/WebSocketAgents.java b/core/src/main/java/jenkins/slaves/WebSocketAgents.java index ee48cb1867d7..22c571322a63 100644 --- a/core/src/main/java/jenkins/slaves/WebSocketAgents.java +++ b/core/src/main/java/jenkins/slaves/WebSocketAgents.java @@ -34,8 +34,6 @@ import hudson.remoting.ChannelBuilder; import hudson.slaves.JNLPLauncher; import hudson.slaves.SlaveComputer; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; @@ -58,8 +56,6 @@ @Restricted(NoExternalUse.class) public final class WebSocketAgents extends InvisibleAction implements UnprotectedRootAction { - private static final String CAPABILITY_KEY = /* Capability.class.getName() */"hudson.remoting.Capability"; - private static final Logger LOGGER = Logger.getLogger(WebSocketAgents.class.getName()); @Override @@ -70,7 +66,7 @@ public String getUrlName() { public HttpResponse doIndex( @Header(value = JnlpConnectionState.CLIENT_NAME_KEY, required = true) String agent, @Header(value = JnlpConnectionState.SECRET_KEY, required = true) String secret, - @Header(value = CAPABILITY_KEY, required = true) String remoteCapabilityStr, + @Header(value = Capability.KEY, required = true) String remoteCapabilityStr, StaplerResponse rsp) throws IOException { Computer c = Jenkins.get().getComputer(agent); if (!(c instanceof SlaveComputer)) { @@ -84,15 +80,9 @@ public HttpResponse doIndex( throw HttpResponses.forbidden(); } LOGGER.fine(() -> "connecting " + agent); - Capability remoteCapability; - try (ByteArrayInputStream bais = new ByteArrayInputStream(remoteCapabilityStr.getBytes(StandardCharsets.US_ASCII))) { - remoteCapability = Capability.read(bais); - LOGGER.fine(() -> "received " + remoteCapability); - } - try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { - new Capability().write(baos); - rsp.setHeader(CAPABILITY_KEY, baos.toString("US-ASCII")); - } + Capability remoteCapability = Capability.fromASCII(remoteCapabilityStr); + LOGGER.fine(() -> "received " + remoteCapability); + rsp.setHeader(Capability.KEY, new Capability().toASCII()); return WebSockets.upgrade(new Session(agent, sc, remoteCapability)); } diff --git a/core/src/main/resources/hudson/slaves/JNLPLauncher/config.jelly b/core/src/main/resources/hudson/slaves/JNLPLauncher/config.jelly index d0765ddb5b5e..a718a0244f00 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..d88155279a41 --- /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. + TODO expand on the implications +
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/pom.xml b/pom.xml index fedf094e3604..edf2300d5427 100755 --- a/pom.xml +++ b/pom.xml @@ -102,7 +102,7 @@ THE SOFTWARE. 3.2.3 - 3.37-rc2650.89397b114373 + 3.37-SNAPSHOT 3.14 diff --git a/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java b/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java index b53ba13e71c4..b3d71a6c3ac6 100644 --- a/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java +++ b/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java @@ -25,25 +25,33 @@ package jenkins.slaves; import hudson.Functions; +import hudson.Proc; import hudson.model.Computer; 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 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 { @ClassRule @@ -53,21 +61,26 @@ public class WebSocketAgentsTest { public JenkinsRule r = new JenkinsRule(); @Rule - public LoggerRule logging = new LoggerRule().record(SlaveComputer.class, Level.FINEST).record(WebSocketAgents.class, Level.FINEST).record(Engine.class, Level.FINEST); + 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(); - @Test - public void smokes() throws Exception { - DumbSlave s = new DumbSlave("remote", tmp.newFolder("agent").getAbsolutePath(), new JNLPLauncher(true)); + @FunctionalInterface + private interface TestLauncher { + void launch(String secret) throws Exception; + } + private void smokeTest(TestLauncher testLauncher) throws Exception { + 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) r.jenkins.getComputer("remote")).getJnlpMac(); - Computer.threadPoolForRemoting.submit(() -> { - // Not as realistic class loading as JNLPLauncherTest.testHeadlessLaunch, but faster to iterate since everything runs inside one JVM. - hudson.remoting.jnlp.Main._main(new String[] {"-headless", "-url", r.getURL().toString(), "-workDir", tmp.newFolder("work").getAbsolutePath(), secret, "remote"}); - return null; - }); + testLauncher.launch(secret); r.waitOnline(s); assertEquals("response", s.getChannel().call(new DummyTask())); FreeStyleProject p = r.createFreeStyleProject(); @@ -76,6 +89,47 @@ public void smokes() throws Exception { r.buildAndAssertSuccess(p); } + @Test + public void inJVM() throws Exception { + smokeTest(secret -> { + Computer.threadPoolForRemoting.submit(() -> { + hudson.remoting.jnlp.Main._main(new String[] { + "-headless", + "-url", r.getURL().toString(), + "-webSocket", + "-workDir", tmp.newFolder("work").getAbsolutePath(), + secret, "remote"}); + return null; + }); + }); + } + + /** + * Slower to run that {@link #inJVM}, and requires {@code remoting} to have been {@code mvn install}ed, but more realistic. + * Unlike {@link hudson.slaves.JNLPLauncherTest} this does not use {@code javaws}; + * closer to {@link hudson.bugs.JnlpAccessWithSecuredHudsonTest}. + * @see hudson.remoting.Launcher + */ + @Test + public void viaLauncher() throws Exception { + AtomicReference proc = new AtomicReference<>(); + try { + smokeTest(secret -> { + 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()); + }); + } finally { + if (proc.get() != null) { + proc.get().kill(); + } + } + } + private static class DummyTask extends SlaveToMasterCallable { @Override public String call() { From 4610da4cdcdd5e6a362335900532bebabfa877f7 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Tue, 10 Dec 2019 16:32:48 -0500 Subject: [PATCH 22/59] Handling some close and error methods. --- .../main/java/jenkins/slaves/WebSocketAgents.java | 7 ++++--- .../java/jenkins/websocket/WebSocketSession.java | 12 +++++++++++- .../java/jenkins/slaves/WebSocketAgentsTest.java | 7 +++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/jenkins/slaves/WebSocketAgents.java b/core/src/main/java/jenkins/slaves/WebSocketAgents.java index 22c571322a63..4b2e2afb8e0f 100644 --- a/core/src/main/java/jenkins/slaves/WebSocketAgents.java +++ b/core/src/main/java/jenkins/slaves/WebSocketAgents.java @@ -32,6 +32,7 @@ import hudson.remoting.Capability; import hudson.remoting.Channel; import hudson.remoting.ChannelBuilder; +import hudson.remoting.ChannelClosedException; import hudson.slaves.JNLPLauncher; import hudson.slaves.SlaveComputer; import java.io.IOException; @@ -122,7 +123,7 @@ protected void binary(byte[] payload, int offset, int len) { @Override protected void closed(int statusCode, String reason) { LOGGER.finest(() -> "closed " + statusCode + " " + reason); - // TODO + receiver.terminate(new ChannelClosedException(sc.getChannel(), null)); } @Override @@ -161,13 +162,13 @@ public Capability getRemoteCapability() throws IOException { @Override public void closeWrite() throws IOException { LOGGER.finest(() -> "closeWrite"); - // TODO + close(); } @Override public void closeRead() throws IOException { LOGGER.finest(() -> "closeRead"); - // TODO + close(); } } diff --git a/core/src/main/java/jenkins/websocket/WebSocketSession.java b/core/src/main/java/jenkins/websocket/WebSocketSession.java index ca40550f9197..e4b44340e422 100644 --- a/core/src/main/java/jenkins/websocket/WebSocketSession.java +++ b/core/src/main/java/jenkins/websocket/WebSocketSession.java @@ -52,6 +52,7 @@ public abstract class WebSocketSession { private static final Logger LOGGER = Logger.getLogger(WebSocketSession.class.getName()); + private Object session; private Object remoteEndpoint; private ScheduledFuture pings; @@ -60,7 +61,8 @@ protected WebSocketSession() {} Object onWebSocketSomething(Object proxy, Method method, Object[] args) throws Exception { switch (method.getName()) { case "onWebSocketConnect": - this.remoteEndpoint = args[0].getClass().getMethod("getRemote").invoke(args[0]); + this.session = args[0]; + this.remoteEndpoint = session.getClass().getMethod("getRemote").invoke(args[0]); if (keepAlive()) { pings = Timer.get().scheduleAtFixedRate(() -> { try { @@ -132,4 +134,12 @@ protected final Future sendText(String text) { } } + protected final void close() { + try { + session.getClass().getMethod("close").invoke(session); + } catch (Exception x) { + throw new RuntimeException(x); + } + } + } diff --git a/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java b/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java index b3d71a6c3ac6..1ed7b67d9f0c 100644 --- a/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java +++ b/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java @@ -38,6 +38,7 @@ 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; @@ -54,6 +55,8 @@ @Issue("JEP-222") public class WebSocketAgentsTest { + private static final Logger LOGGER = Logger.getLogger(WebSocketAgentsTest.class.getName()); + @ClassRule public static BuildWatcher buildWatcher = new BuildWatcher(); @@ -126,6 +129,10 @@ public void viaLauncher() throws Exception { } 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); + } } } } From 54707015d84cb6bdf960e603a479db19f6bd46bf Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Wed, 11 Dec 2019 13:53:44 -0500 Subject: [PATCH 23/59] Sending X-Remoting-Minimum-Version. --- core/src/main/java/jenkins/slaves/WebSocketAgents.java | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/main/java/jenkins/slaves/WebSocketAgents.java b/core/src/main/java/jenkins/slaves/WebSocketAgents.java index 4b2e2afb8e0f..ac0997c22a58 100644 --- a/core/src/main/java/jenkins/slaves/WebSocketAgents.java +++ b/core/src/main/java/jenkins/slaves/WebSocketAgents.java @@ -84,6 +84,7 @@ public HttpResponse doIndex( Capability remoteCapability = Capability.fromASCII(remoteCapabilityStr); LOGGER.fine(() -> "received " + remoteCapability); rsp.setHeader(Capability.KEY, new Capability().toASCII()); + rsp.setHeader("X-Remoting-Minimum-Version", RemotingVersionInfo.getMinimumSupportedVersion().toString()); return WebSockets.upgrade(new Session(agent, sc, remoteCapability)); } From 3f826975e239504c4f734e9e42d5e5bdaf4caab2 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Wed, 11 Dec 2019 16:22:15 -0500 Subject: [PATCH 24/59] If hudson.remoting.jnlp.Main._main fails, show the error. --- .../java/hudson/triggers/SafeTimerTask.java | 21 +++++++++++++++++++ .../jenkins/slaves/WebSocketAgentsTest.java | 6 +++--- 2 files changed, 24 insertions(+), 3 deletions(-) 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/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java b/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java index 1ed7b67d9f0c..ee7e96dc72ab 100644 --- a/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java +++ b/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java @@ -35,6 +35,7 @@ import hudson.slaves.SlaveComputer; import hudson.tasks.BatchFile; import hudson.tasks.Shell; +import hudson.triggers.SafeTimerTask; import java.io.File; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; @@ -95,15 +96,14 @@ private void smokeTest(TestLauncher testLauncher) throws Exception { @Test public void inJVM() throws Exception { smokeTest(secret -> { - Computer.threadPoolForRemoting.submit(() -> { + Computer.threadPoolForRemoting.submit(SafeTimerTask.of(() -> { hudson.remoting.jnlp.Main._main(new String[] { "-headless", "-url", r.getURL().toString(), "-webSocket", "-workDir", tmp.newFolder("work").getAbsolutePath(), secret, "remote"}); - return null; - }); + })); }); } From b297665de6391bfcbee86c900e43dc024c9999c8 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Wed, 11 Dec 2019 16:28:56 -0500 Subject: [PATCH 25/59] Comment. --- test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java b/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java index ee7e96dc72ab..d8a6a08208ce 100644 --- a/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java +++ b/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java @@ -108,7 +108,9 @@ public void inJVM() throws Exception { } /** - * Slower to run that {@link #inJVM}, and requires {@code remoting} to have been {@code mvn install}ed, but more realistic. + * More realistic version of {@link #inJVM}. + * On the other hand it is slower; requires {@code remoting} to have been {@code mvn install}ed; + * and 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 From 083ae830f2adfdcd02e8778cdfc369db555229e2 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Wed, 11 Dec 2019 16:32:38 -0500 Subject: [PATCH 26/59] GUI analogue of https://github.com/jenkinsci/remoting/pull/357/commits/86cea5b83fd2fc93a9fc10896962eb42c95f80a0. --- core/src/main/java/hudson/slaves/JNLPLauncher.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/hudson/slaves/JNLPLauncher.java b/core/src/main/java/hudson/slaves/JNLPLauncher.java index 3169ad39e958..b6d0e0cca3fb 100644 --- a/core/src/main/java/hudson/slaves/JNLPLauncher.java +++ b/core/src/main/java/hudson/slaves/JNLPLauncher.java @@ -29,6 +29,7 @@ import hudson.model.Descriptor; import hudson.model.DescriptorVisibilityFilter; import hudson.model.TaskListener; +import hudson.util.FormValidation; import javax.annotation.CheckForNull; import javax.annotation.Nonnull; @@ -40,6 +41,7 @@ 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. @@ -208,7 +210,12 @@ public boolean isWorkDirSupported() { return DescriptorImpl.class.equals(getClass()); } - // TODO form validation: webSocket and tunnel probably mutually exclusive + public FormValidation doCheckWebSocket(@QueryParameter boolean webSocket, @QueryParameter String tunnel) { + if (webSocket && tunnel != null) { + return FormValidation.error("-tunnel is not currently supported in -webSocket mode"); + } + return FormValidation.ok(); + } } From 5fd4dcfdf5124510e345d2e795db95ba50bd2575 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Wed, 11 Dec 2019 16:40:44 -0500 Subject: [PATCH 27/59] Missing since tags. --- core/src/main/java/hudson/slaves/JNLPLauncher.java | 6 ++++++ core/src/main/java/jenkins/websocket/WebSocketSession.java | 1 + core/src/main/java/jenkins/websocket/WebSockets.java | 1 + 3 files changed, 8 insertions(+) diff --git a/core/src/main/java/hudson/slaves/JNLPLauncher.java b/core/src/main/java/hudson/slaves/JNLPLauncher.java index b6d0e0cca3fb..6def3c4eff34 100644 --- a/core/src/main/java/hudson/slaves/JNLPLauncher.java +++ b/core/src/main/java/hudson/slaves/JNLPLauncher.java @@ -147,10 +147,16 @@ public boolean isLaunchSupported() { return false; } + /** + * @since TODO + */ public boolean isWebSocket() { return webSocket; } + /** + * @since TODO + */ @DataBoundSetter public void setWebSocket(boolean webSocket) { this.webSocket = webSocket; diff --git a/core/src/main/java/jenkins/websocket/WebSocketSession.java b/core/src/main/java/jenkins/websocket/WebSocketSession.java index e4b44340e422..947f90e6d11e 100644 --- a/core/src/main/java/jenkins/websocket/WebSocketSession.java +++ b/core/src/main/java/jenkins/websocket/WebSocketSession.java @@ -38,6 +38,7 @@ /** * One WebSocket connection. * @see WebSockets + * @since TODO */ @Restricted(Beta.class) public abstract class WebSocketSession { diff --git a/core/src/main/java/jenkins/websocket/WebSockets.java b/core/src/main/java/jenkins/websocket/WebSockets.java index b3ac0b8bce2d..c7eba9797db3 100644 --- a/core/src/main/java/jenkins/websocket/WebSockets.java +++ b/core/src/main/java/jenkins/websocket/WebSockets.java @@ -41,6 +41,7 @@ /** * Support for serving WebSocket responses. + * @since TODO */ @Restricted(Beta.class) @Extension From 0771486cdf8f90866f382e2d36183f8fc78256fb Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Wed, 11 Dec 2019 16:45:54 -0500 Subject: [PATCH 28/59] Capitalization. --- core/src/main/resources/hudson/slaves/JNLPLauncher/config.jelly | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/resources/hudson/slaves/JNLPLauncher/config.jelly b/core/src/main/resources/hudson/slaves/JNLPLauncher/config.jelly index a718a0244f00..6fd90a96d14b 100644 --- a/core/src/main/resources/hudson/slaves/JNLPLauncher/config.jelly +++ b/core/src/main/resources/hudson/slaves/JNLPLauncher/config.jelly @@ -29,7 +29,7 @@ THE SOFTWARE. - + From 5b792d26e1357caaedeac8a6cefa7bb6f4c4f1ac Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Wed, 11 Dec 2019 16:46:12 -0500 Subject: [PATCH 29/59] WebSockets.isSupported --- core/src/main/java/hudson/slaves/JNLPLauncher.java | 10 ++++++++-- .../src/main/java/jenkins/slaves/WebSocketAgents.java | 2 +- core/src/main/java/jenkins/websocket/WebSockets.java | 11 ++++++++++- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/hudson/slaves/JNLPLauncher.java b/core/src/main/java/hudson/slaves/JNLPLauncher.java index 6def3c4eff34..e1fa051e26af 100644 --- a/core/src/main/java/hudson/slaves/JNLPLauncher.java +++ b/core/src/main/java/hudson/slaves/JNLPLauncher.java @@ -36,6 +36,7 @@ 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; @@ -217,8 +218,13 @@ public boolean isWorkDirSupported() { } public FormValidation doCheckWebSocket(@QueryParameter boolean webSocket, @QueryParameter String tunnel) { - if (webSocket && tunnel != null) { - return FormValidation.error("-tunnel is not currently supported in -webSocket mode"); + if (webSocket) { + if (!WebSockets.isSupported()) { + return FormValidation.error("WebSocket support is not enabled in this Jenkins installation"); + } + if (tunnel != null) { + return FormValidation.error("Tunneling is not currently supported in WebSocket mode"); + } } return FormValidation.ok(); } diff --git a/core/src/main/java/jenkins/slaves/WebSocketAgents.java b/core/src/main/java/jenkins/slaves/WebSocketAgents.java index ac0997c22a58..46cdbcb2c500 100644 --- a/core/src/main/java/jenkins/slaves/WebSocketAgents.java +++ b/core/src/main/java/jenkins/slaves/WebSocketAgents.java @@ -61,7 +61,7 @@ public final class WebSocketAgents extends InvisibleAction implements Unprotecte @Override public String getUrlName() { - return "wsagents"; + return WebSockets.isSupported() ? "wsagents" : null; } public HttpResponse doIndex( diff --git a/core/src/main/java/jenkins/websocket/WebSockets.java b/core/src/main/java/jenkins/websocket/WebSockets.java index c7eba9797db3..2d856d5069cb 100644 --- a/core/src/main/java/jenkins/websocket/WebSockets.java +++ b/core/src/main/java/jenkins/websocket/WebSockets.java @@ -51,7 +51,6 @@ public class WebSockets { private static final String ATTR_SESSION = WebSockets.class.getName() + ".session"; - // TODO method to see if WS are supported in this container // TODO ability to handle subprotocols? public static HttpResponse upgrade(WebSocketSession session) { @@ -85,6 +84,16 @@ private static synchronized void staticInit() throws Exception { } } + 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 { From 89f28b8ca7896dba38c50627b41a1756f9f0a3bc Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Wed, 11 Dec 2019 17:13:54 -0500 Subject: [PATCH 30/59] Rather than hiding JNLPLauncher.DescriptorImpl when the TCP port is disabled, always display it, but show form validation appropriate to WebSocket or TCP mode. --- .../main/java/hudson/slaves/JNLPLauncher.java | 26 +++---------------- 1 file changed, 4 insertions(+), 22 deletions(-) diff --git a/core/src/main/java/hudson/slaves/JNLPLauncher.java b/core/src/main/java/hudson/slaves/JNLPLauncher.java index e1fa051e26af..af64ce9c0661 100644 --- a/core/src/main/java/hudson/slaves/JNLPLauncher.java +++ b/core/src/main/java/hudson/slaves/JNLPLauncher.java @@ -27,7 +27,6 @@ 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; @@ -225,33 +224,16 @@ public FormValidation doCheckWebSocket(@QueryParameter boolean webSocket, @Query if (tunnel != null) { return FormValidation.error("Tunneling is not currently 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(); } } - /** - * Hides the JNLP launcher when the JNLP agent port is not enabled. - * - * @since 2.16 - */ - @Extension - public static class DescriptorVisibilityFilterImpl extends DescriptorVisibilityFilter { - - // TODO allow it when WebSocket is available - - @Override - public boolean filter(@CheckForNull Object context, @Nonnull Descriptor descriptor) { - return descriptor.clazz != JNLPLauncher.class || Jenkins.get().getTcpSlaveAgentListener() != null; - } - - @Override - public boolean filterType(@Nonnull Class contextClass, @Nonnull Descriptor descriptor) { - return descriptor.clazz != JNLPLauncher.class || Jenkins.get().getTcpSlaveAgentListener() != null; - } - } - /** * Returns true if Java Web Start button should be displayed. * Java Web Start is only supported when the Jenkins server is From 266dc8875a94ec7d8d966bb35111d9a7b272e3a7 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Thu, 12 Dec 2019 10:48:32 -0500 Subject: [PATCH 31/59] Form validation fixes. --- core/src/main/java/hudson/slaves/JNLPLauncher.java | 2 +- core/src/main/resources/hudson/slaves/JNLPLauncher/config.jelly | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/hudson/slaves/JNLPLauncher.java b/core/src/main/java/hudson/slaves/JNLPLauncher.java index af64ce9c0661..7aec4b7cf209 100644 --- a/core/src/main/java/hudson/slaves/JNLPLauncher.java +++ b/core/src/main/java/hudson/slaves/JNLPLauncher.java @@ -221,7 +221,7 @@ public FormValidation doCheckWebSocket(@QueryParameter boolean webSocket, @Query if (!WebSockets.isSupported()) { return FormValidation.error("WebSocket support is not enabled in this Jenkins installation"); } - if (tunnel != null) { + if (Util.fixEmptyAndTrim(tunnel) != null) { return FormValidation.error("Tunneling is not currently supported in WebSocket mode"); } } else { diff --git a/core/src/main/resources/hudson/slaves/JNLPLauncher/config.jelly b/core/src/main/resources/hudson/slaves/JNLPLauncher/config.jelly index 6fd90a96d14b..6b33de6b8068 100644 --- a/core/src/main/resources/hudson/slaves/JNLPLauncher/config.jelly +++ b/core/src/main/resources/hudson/slaves/JNLPLauncher/config.jelly @@ -28,7 +28,7 @@ THE SOFTWARE. - + From f32ee18c1261e15a1db2b53ca5203f77be721f51 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Fri, 13 Dec 2019 17:23:29 -0500 Subject: [PATCH 32/59] Minor test improvements. --- test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java b/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java index d8a6a08208ce..ab11aa1aca82 100644 --- a/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java +++ b/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java @@ -83,7 +83,7 @@ private void smokeTest(TestLauncher testLauncher) throws Exception { launcher.setWebSocket(true); DumbSlave s = new DumbSlave("remote", tmp.newFolder("agent").getAbsolutePath(), launcher); r.jenkins.addNode(s); - String secret = ((SlaveComputer) r.jenkins.getComputer("remote")).getJnlpMac(); + String secret = ((SlaveComputer) s.toComputer()).getJnlpMac(); testLauncher.launch(secret); r.waitOnline(s); assertEquals("response", s.getChannel().call(new DummyTask())); @@ -91,6 +91,7 @@ private void smokeTest(TestLauncher testLauncher) throws Exception { 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); } @Test From beaa296e6af352c7314c4f544b43603e7f5c1535 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Fri, 13 Dec 2019 17:26:23 -0500 Subject: [PATCH 33/59] Reworked WebSocketAgents to be compatible with JnlpAgentReceiver. --- .../slaves/DefaultJnlpSlaveReceiver.java | 4 +- .../jenkins/slaves/JnlpAgentReceiver.java | 2 +- .../java/jenkins/slaves/WebSocketAgents.java | 60 ++++++++++++------- 3 files changed, 40 insertions(+), 26 deletions(-) diff --git a/core/src/main/java/jenkins/slaves/DefaultJnlpSlaveReceiver.java b/core/src/main/java/jenkins/slaves/DefaultJnlpSlaveReceiver.java index 125084320ca7..32aadcdcc46c 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 39473d75d658..3effde30df5e 100644 --- a/core/src/main/java/jenkins/slaves/JnlpAgentReceiver.java +++ b/core/src/main/java/jenkins/slaves/JnlpAgentReceiver.java @@ -10,7 +10,7 @@ import org.jenkinsci.remoting.engine.JnlpConnectionStateListener; /** - * Receives incoming agents connecting through {@link JnlpSlaveAgentProtocol2}, {@link JnlpSlaveAgentProtocol3}, {@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/slaves/WebSocketAgents.java b/core/src/main/java/jenkins/slaves/WebSocketAgents.java index 46cdbcb2c500..66c2ac2273a4 100644 --- a/core/src/main/java/jenkins/slaves/WebSocketAgents.java +++ b/core/src/main/java/jenkins/slaves/WebSocketAgents.java @@ -24,7 +24,9 @@ package jenkins.slaves; +import com.google.common.collect.ImmutableMap; import hudson.Extension; +import hudson.ExtensionList; import hudson.model.Computer; import hudson.model.InvisibleAction; import hudson.model.UnprotectedRootAction; @@ -32,25 +34,23 @@ import hudson.remoting.Capability; import hudson.remoting.Channel; import hudson.remoting.ChannelBuilder; -import hudson.remoting.ChannelClosedException; -import hudson.slaves.JNLPLauncher; -import hudson.slaves.SlaveComputer; 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.model.Jenkins; 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.Header; import org.kohsuke.stapler.HttpResponse; import org.kohsuke.stapler.HttpResponses; +import org.kohsuke.stapler.StaplerRequest; import org.kohsuke.stapler.StaplerResponse; @Extension @@ -64,40 +64,50 @@ public String getUrlName() { return WebSockets.isSupported() ? "wsagents" : null; } - public HttpResponse doIndex( - @Header(value = JnlpConnectionState.CLIENT_NAME_KEY, required = true) String agent, - @Header(value = JnlpConnectionState.SECRET_KEY, required = true) String secret, - @Header(value = Capability.KEY, required = true) String remoteCapabilityStr, - StaplerResponse rsp) throws IOException { - Computer c = Jenkins.get().getComputer(agent); - if (!(c instanceof SlaveComputer)) { - throw HttpResponses.notFound(); + 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"); } - SlaveComputer sc = (SlaveComputer) c; - if (!(sc.getLauncher() instanceof JNLPLauncher)) { - throw HttpResponses.errorWithoutStack(400, "not an inbound agent"); + 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), sc.getJnlpMac().getBytes(StandardCharsets.US_ASCII))) { // TODO unless anonymous has CONNECT? + if (!MessageDigest.isEqual(secret.getBytes(StandardCharsets.US_ASCII), JnlpAgentReceiver.DATABASE.getSecretOf(agent).getBytes(StandardCharsets.US_ASCII))) { // TODO unless anonymous has CONNECT? + 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("X-Remoting-Minimum-Version", RemotingVersionInfo.getMinimumSupportedVersion().toString()); - return WebSockets.upgrade(new Session(agent, sc, remoteCapability)); + 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 SlaveComputer sc; private final Capability remoteCapability; private AbstractByteArrayCommandTransport.ByteArrayReceiver receiver; - Session(String agent, SlaveComputer sc, Capability remoteCapability) { + Session(JnlpConnectionState state, String agent, Capability remoteCapability) { + this.state = state; this.agent = agent; - this.sc = sc; this.remoteCapability = remoteCapability; } @@ -105,7 +115,8 @@ private static class Session extends WebSocketSession { protected void opened() { Computer.threadPoolForRemoting.submit(() -> { LOGGER.fine(() -> "setting up channel for " + agent); - sc.setChannel(new ChannelBuilder(agent, Computer.threadPoolForRemoting).withHeaderStream(sc.openLogFile()), new Transport(), null); + state.fireBeforeChannel(new ChannelBuilder(agent, Computer.threadPoolForRemoting)); + state.fireAfterChannel(state.getChannelBuilder().build(new Transport())); LOGGER.fine(() -> "set up channel for " + agent); return null; }); @@ -124,7 +135,10 @@ protected void binary(byte[] payload, int offset, int len) { @Override protected void closed(int statusCode, String reason) { LOGGER.finest(() -> "closed " + statusCode + " " + reason); - receiver.terminate(new ChannelClosedException(sc.getChannel(), null)); + IOException x = new ClosedChannelException(); + receiver.terminate(x); + state.fireChannelClosed(x); + state.fireAfterDisconnect(); } @Override From 4f19381822b370154b4d233c8417fa2c085eeb37 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Mon, 16 Dec 2019 16:18:25 -0500 Subject: [PATCH 34/59] Removing WebSocketSession.keepAlive in favor of a global setting applicable to all services. --- .../java/jenkins/slaves/WebSocketAgents.java | 5 ----- .../java/jenkins/websocket/WebSocketEcho.java | 4 ---- .../jenkins/websocket/WebSocketSession.java | 18 ++++++++++-------- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/core/src/main/java/jenkins/slaves/WebSocketAgents.java b/core/src/main/java/jenkins/slaves/WebSocketAgents.java index 66c2ac2273a4..03fb8972e784 100644 --- a/core/src/main/java/jenkins/slaves/WebSocketAgents.java +++ b/core/src/main/java/jenkins/slaves/WebSocketAgents.java @@ -146,11 +146,6 @@ protected void error(Throwable cause) { LOGGER.log(Level.WARNING, null, cause); } - @Override - protected boolean keepAlive() { - return true; // Remoting ping thread may be too slow - } - class Transport extends AbstractByteArrayCommandTransport { @Override diff --git a/core/src/main/java/jenkins/websocket/WebSocketEcho.java b/core/src/main/java/jenkins/websocket/WebSocketEcho.java index ebe89c2fbb0b..0071c8bdee75 100644 --- a/core/src/main/java/jenkins/websocket/WebSocketEcho.java +++ b/core/src/main/java/jenkins/websocket/WebSocketEcho.java @@ -43,10 +43,6 @@ public String getUrlName() { public HttpResponse doIndex() { return WebSockets.upgrade(new WebSocketSession() { - @Override - protected boolean keepAlive() { - return true; - } @Override protected void text(String message) { sendText("hello " + message); diff --git a/core/src/main/java/jenkins/websocket/WebSocketSession.java b/core/src/main/java/jenkins/websocket/WebSocketSession.java index 947f90e6d11e..a74ba1ff75cc 100644 --- a/core/src/main/java/jenkins/websocket/WebSocketSession.java +++ b/core/src/main/java/jenkins/websocket/WebSocketSession.java @@ -31,6 +31,7 @@ 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; @@ -44,12 +45,17 @@ public abstract class WebSocketSession { /** - * Number of seconds between server-sent pings, if enabled. - * nginx docs claim 60s timeout and this seems to match experiments. + * 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 final long PING_INTERVAL_SECONDS = 30; + private static long PING_INTERVAL_SECONDS = SystemProperties.getLong("jenkins.websocket.pingInterval", 30L); private static final Logger LOGGER = Logger.getLogger(WebSocketSession.class.getName()); @@ -64,7 +70,7 @@ Object onWebSocketSomething(Object proxy, Method method, Object[] args) throws E case "onWebSocketConnect": this.session = args[0]; this.remoteEndpoint = session.getClass().getMethod("getRemote").invoke(args[0]); - if (keepAlive()) { + if (PING_INTERVAL_SECONDS != 0) { pings = Timer.get().scheduleAtFixedRate(() -> { try { remoteEndpoint.getClass().getMethod("sendPing", ByteBuffer.class).invoke(remoteEndpoint, ByteBuffer.wrap(new byte[0])); @@ -97,10 +103,6 @@ Object onWebSocketSomething(Object proxy, Method method, Object[] args) throws E } } - protected boolean keepAlive() { - return false; - } - protected void opened() { } From cbb0c6746f3fba660459932e5862c6e104c93c28 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Tue, 17 Dec 2019 14:11:17 -0500 Subject: [PATCH 35/59] Add a -webSocket option to the Jenkins CLI. --- cli/pom.xml | 6 + cli/src/main/java/hudson/cli/CLI.java | 203 +++++++++---- .../java/hudson/cli/PlainCLIProtocol.java | 221 +++++++------- .../java/hudson/cli/PlainCLIProtocolTest.java | 11 +- core/src/main/java/hudson/cli/CLIAction.java | 270 +++++++++++------- .../jenkins/websocket/WebSocketSession.java | 2 + .../test/java/hudson/cli/CLIActionTest.java | 14 +- test/src/test/java/hudson/cli/CLITest.java | 4 +- 8 files changed, 464 insertions(+), 267 deletions(-) diff --git a/cli/pom.xml b/cli/pom.xml index ab57a95d9ffb..8d929c81160f 100644 --- a/cli/pom.xml +++ b/cli/pom.xml @@ -93,6 +93,12 @@ slf4j-jdk14 true + + org.glassfish.tyrus.bundles + tyrus-standalone-client-jdk + 1.12 + true + com.github.spotbugs spotbugs-annotations 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/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..7e4fb3ee2ed1 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,69 @@ public void doCommand(StaplerRequest req, StaplerResponse rsp) throws ServletExc req.getView(this, "command.jelly").forward(req, rsp); } + /** + * 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 +185,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 +298,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/jenkins/websocket/WebSocketSession.java b/core/src/main/java/jenkins/websocket/WebSocketSession.java index a74ba1ff75cc..95a7ed76094c 100644 --- a/core/src/main/java/jenkins/websocket/WebSocketSession.java +++ b/core/src/main/java/jenkins/websocket/WebSocketSession.java @@ -114,9 +114,11 @@ protected void error(Throwable 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") 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 14ff043c6813..5b4f81fe7fa8 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 { assertEquals(rsp.getContentAsString(), null, rsp.getResponseHeaderValue("X-Jenkins-CLI-Port")); assertEquals(rsp.getContentAsString(), null, 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(); From 0d49c3698ce4b8b50f1a412219dc008c4b346ce0 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Tue, 17 Dec 2019 15:07:32 -0500 Subject: [PATCH 36/59] Using a snapshot deployment of Remoting, pending INFRA-2379. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index edf2300d5427..9968dd24e86b 100755 --- a/pom.xml +++ b/pom.xml @@ -102,7 +102,7 @@ THE SOFTWARE. 3.2.3 - 3.37-SNAPSHOT + 3.37-20191217.200448-2 3.14 From d56fd177840f6c0535fb2d562802766c923e4b03 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Tue, 17 Dec 2019 16:05:17 -0500 Subject: [PATCH 37/59] After #3838 there is no reason to recheck authentication after parsing CLICommand arguments. --- core/src/main/java/hudson/cli/CLICommand.java | 2 -- 1 file changed, 2 deletions(-) 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(); From 24ecb0f477b39ab0e7ee0411b254ef5617fd4542 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Tue, 17 Dec 2019 16:08:28 -0500 Subject: [PATCH 38/59] Advertise the -webSocket option. --- cli/src/main/resources/hudson/cli/client/Messages.properties | 1 + core/src/main/java/hudson/cli/CLIAction.java | 5 +++++ core/src/main/resources/hudson/cli/CLIAction/example.jelly | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) 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/core/src/main/java/hudson/cli/CLIAction.java b/core/src/main/java/hudson/cli/CLIAction.java index 7e4fb3ee2ed1..102dbbc00b0d 100644 --- a/core/src/main/java/hudson/cli/CLIAction.java +++ b/core/src/main/java/hudson/cli/CLIAction.java @@ -106,6 +106,11 @@ 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. */ 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}
From 13bc62c24fc0fd1b9ae19e9b8edb64087d2aa649 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Tue, 17 Dec 2019 16:48:48 -0500 Subject: [PATCH 39/59] Adapt to newer HtmlUnit. --- test-pom/pom.xml | 2 +- test/src/test/java/jenkins/security/ResourceDomainTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test-pom/pom.xml b/test-pom/pom.xml index 936e8eec63b2..50ecdfda2cb3 100644 --- a/test-pom/pom.xml +++ b/test-pom/pom.xml @@ -71,7 +71,7 @@ THE SOFTWARE. ${project.groupId} jenkins-test-harness - 2.57 + 2.58-20191217.214741-2 test diff --git a/test/src/test/java/jenkins/security/ResourceDomainTest.java b/test/src/test/java/jenkins/security/ResourceDomainTest.java index c3432ba36753..5c5c335eb229 100644 --- a/test/src/test/java/jenkins/security/ResourceDomainTest.java +++ b/test/src/test/java/jenkins/security/ResourceDomainTest.java @@ -99,7 +99,7 @@ public void secondDomainBasics() throws Exception { { webClient.setThrowExceptionOnFailingStatusCode(false); - Page page = webClient.getPage(resourceRootUrl + "/static-files"); + Page page = webClient.getPage(resourceRootUrl + "/static-files/"); Assert.assertEquals("resource action index page response is 404", 404, page.getWebResponse().getStatusCode()); } From 4a53dfe49a88cf19a39f9e065faea480201f9696 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Tue, 17 Dec 2019 19:53:39 -0500 Subject: [PATCH 40/59] 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. --- .../java/hudson/diagnosis/HudsonHomeDiskUsageMonitorTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/src/test/java/hudson/diagnosis/HudsonHomeDiskUsageMonitorTest.java b/test/src/test/java/hudson/diagnosis/HudsonHomeDiskUsageMonitorTest.java index 975b6ea1bcae..1a29a8fa8f58 100644 --- a/test/src/test/java/hudson/diagnosis/HudsonHomeDiskUsageMonitorTest.java +++ b/test/src/test/java/hudson/diagnosis/HudsonHomeDiskUsageMonitorTest.java @@ -96,6 +96,8 @@ public void noAccessForNonAdmin() throws Exception { assertEquals(HttpURLConnection.HTTP_FORBIDDEN, p.getWebResponse().getStatusCode()); wc.withBasicApiToken(administrator); + request = new WebRequest(new URL(wc.getContextPath() + "administrativeMonitor/hudsonHomeIsFull/act"), HttpMethod.POST); + request.setRequestParameters(Collections.singletonList(param)); p = wc.getPage(request); assertEquals(HttpURLConnection.HTTP_OK, p.getWebResponse().getStatusCode()); assertFalse(mon.isEnabled()); From 53469113d2ef7fbe7ce5a23320de9f953d3c4d01 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Tue, 17 Dec 2019 21:01:44 -0500 Subject: [PATCH 41/59] 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. --- test/src/test/java/hudson/PluginTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/src/test/java/hudson/PluginTest.java b/test/src/test/java/hudson/PluginTest.java index 7b05f96e8b1d..b4a2217b27e9 100644 --- a/test/src/test/java/hudson/PluginTest.java +++ b/test/src/test/java/hudson/PluginTest.java @@ -48,9 +48,11 @@ public class PluginTest { ((TestPluginManager) r.jenkins.pluginManager).installDetachedPlugin("matrix-auth"); r.createWebClient().goTo("plugin/matrix-auth/images/user-disabled.png", "image/png"); r.createWebClient().goTo("plugin/matrix-auth/images/../images/user-disabled.png", "image/png"); // collapsed somewhere before it winds up in restOfPath + /* TODO https://github.com/apache/httpcomponents-client/commit/8c04c6ae5e5ba1432e40684428338ce68431766b#r32873542 r.createWebClient().assertFails("plugin/matrix-auth/images/%2E%2E/images/user-disabled.png", HttpServletResponse.SC_INTERNAL_SERVER_ERROR); // IAE from TokenList. r.createWebClient().assertFails("plugin/matrix-auth/images/%252E%252E/images/user-disabled.png", HttpServletResponse.SC_BAD_REQUEST); // SECURITY-131 r.createWebClient().assertFails("plugin/matrix-auth/images/%25252E%25252E/images/user-disabled.png", HttpServletResponse.SC_BAD_REQUEST); // just checking + */ // SECURITY-705: r.createWebClient().assertFails("plugin/matrix-auth/images/..%2fWEB-INF/licenses.xml", HttpServletResponse.SC_BAD_REQUEST); r.createWebClient().assertFails("plugin/matrix-auth/./matrix-auth.jpi", /* Path collapsed to simply `credentials.jpi` before entering */ HttpServletResponse.SC_NOT_FOUND); From e7efeb68a6af02f345983c93a6bec7575c3fef42 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Fri, 20 Dec 2019 22:43:15 -0500 Subject: [PATCH 42/59] Disabled TCP port does not matter in WebSocket mode. --- core/src/main/resources/hudson/slaves/JNLPLauncher/main.jelly | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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}. From 5845105addbf4525df16a5ac12788010a38b74b5 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Fri, 20 Dec 2019 22:45:23 -0500 Subject: [PATCH 43/59] Shade dependencies needed for jenkins-cli.jar. --- cli/pom.xml | 87 ++++++++++++++-------- core/src/main/java/hudson/model/Slave.java | 24 +++--- pom.xml | 2 +- war/pom.xml | 9 --- 4 files changed, 65 insertions(+), 57 deletions(-) diff --git a/cli/pom.xml b/cli/pom.xml index 8d929c81160f..a7bcbfca7d1c 100644 --- a/cli/pom.xml +++ b/cli/pom.xml @@ -53,18 +53,22 @@ org.kohsuke access-modifier-annotation + provided org.jenkins-ci annotation-indexer + true commons-codec commons-codec + true commons-io commons-io + true junit @@ -75,38 +79,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 + true com.github.spotbugs spotbugs-annotations - true + provided commons-lang commons-lang + true @@ -117,29 +124,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/core/src/main/java/hudson/model/Slave.java b/core/src/main/java/hudson/model/Slave.java index e669bfd424dc..f07d25f25bbe 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/pom.xml b/pom.xml index bbfe86627b3a..5f92ab81f691 100755 --- a/pom.xml +++ b/pom.xml @@ -102,7 +102,7 @@ THE SOFTWARE. 3.2.3 - 3.37-20191217.200448-2 + 3.37-SNAPSHOT 3.14 diff --git a/war/pom.xml b/war/pom.xml index beac2129216c..1dccd141b6c7 100644 --- a/war/pom.xml +++ b/war/pom.xml @@ -91,9 +91,7 @@ THE SOFTWARE. ${project.groupId} cli - jar-with-dependencies ${project.version} - provided - - ${project.groupId} - cli - jar-with-dependencies - ${project.build.directory}/${project.build.finalName}/WEB-INF - jenkins-cli.jar - org.jenkins-ci winstone From a05ee415b5adb1a05a11d8771f6a5fef0b550b73 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Fri, 20 Dec 2019 22:54:10 -0500 Subject: [PATCH 44/59] Help text edit. --- .../resources/hudson/slaves/JNLPLauncher/help-webSocket.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/resources/hudson/slaves/JNLPLauncher/help-webSocket.html b/core/src/main/resources/hudson/slaves/JNLPLauncher/help-webSocket.html index d88155279a41..90f5c28f9077 100644 --- a/core/src/main/resources/hudson/slaves/JNLPLauncher/help-webSocket.html +++ b/core/src/main/resources/hudson/slaves/JNLPLauncher/help-webSocket.html @@ -1,4 +1,4 @@
Use WebSocket to connect to the Jenkins master rather than the TCP port. - TODO expand on the implications + See JEP-222 for background.
From bbcd0072628494025ed5d1110f6c86a938f780b7 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Thu, 2 Jan 2020 21:13:37 -0500 Subject: [PATCH 45/59] https://github.com/jenkinsci/winstone/pull/79 was released as 5.5. --- war/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/war/pom.xml b/war/pom.xml index 1dccd141b6c7..dabde51baad3 100644 --- a/war/pom.xml +++ b/war/pom.xml @@ -99,7 +99,7 @@ THE SOFTWARE. --> org.jenkins-ci winstone - 5.5-rc481.482ea78c534a + 5.5 test
From 37d33dcd4f099622a940f2e2e6b8807e1675ca11 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Thu, 2 Jan 2020 21:34:33 -0500 Subject: [PATCH 46/59] https://github.com/jenkinsci/jenkins-test-harness/pull/183 released as 2.59. --- test-pom/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test-pom/pom.xml b/test-pom/pom.xml index e002d3569cf2..d461bc103214 100644 --- a/test-pom/pom.xml +++ b/test-pom/pom.xml @@ -71,7 +71,7 @@ THE SOFTWARE. ${project.groupId} jenkins-test-harness - 2.59-20191220.211707-1 + 2.59 test From 4199ef430f5f24e1898780513858156644270f0c Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Thu, 2 Jan 2020 22:06:41 -0500 Subject: [PATCH 47/59] https://github.com/jenkinsci/jenkins/pull/4387#discussion_r362696234 --- .../main/java/jenkins/slaves/JnlpSlaveAgentProtocol.java | 9 --------- 1 file changed, 9 deletions(-) diff --git a/core/src/main/java/jenkins/slaves/JnlpSlaveAgentProtocol.java b/core/src/main/java/jenkins/slaves/JnlpSlaveAgentProtocol.java index a16a6b83360e..c8a166bf628e 100644 --- a/core/src/main/java/jenkins/slaves/JnlpSlaveAgentProtocol.java +++ b/core/src/main/java/jenkins/slaves/JnlpSlaveAgentProtocol.java @@ -1,18 +1,9 @@ package jenkins.slaves; -import hudson.Extension; -import hudson.ExtensionList; -import hudson.model.Computer; import java.io.IOException; import java.net.Socket; -import java.util.Collections; -import java.util.logging.Logger; -import javax.inject.Inject; import jenkins.AgentProtocol; import jenkins.security.HMACConfidentialKey; -import org.jenkinsci.Symbol; -import org.jenkinsci.remoting.engine.JnlpConnectionState; -import org.jenkinsci.remoting.engine.JnlpProtocol1Handler; /** * {@link AgentProtocol} that accepts connection from agents. From 760556cf8c1bd844faa100445d76a37292ef7603 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Thu, 2 Jan 2020 22:06:58 -0500 Subject: [PATCH 48/59] Bumping remoting to a new deployed snapshot. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 26eb448dc426..d5ac6840b8dd 100755 --- a/pom.xml +++ b/pom.xml @@ -102,7 +102,7 @@ THE SOFTWARE. 3.2.3 - 3.37-SNAPSHOT + 3.41-20200103.025016-1 3.14 From c60571ccdd40de0e8ea9f4fc3813d689db177197 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Fri, 3 Jan 2020 16:45:13 -0500 Subject: [PATCH 49/59] Need https://github.com/jenkinsci/winstone/pull/86. --- war/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/war/pom.xml b/war/pom.xml index dabde51baad3..a91ea26e85da 100644 --- a/war/pom.xml +++ b/war/pom.xml @@ -99,7 +99,7 @@ THE SOFTWARE. --> org.jenkins-ci winstone - 5.5 + 5.6-20200103.213950-2 test From 3e18174551f716b149f840b5bf1683441e955cf3 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Fri, 3 Jan 2020 16:55:11 -0500 Subject: [PATCH 50/59] https://github.com/jenkinsci/winstone/pull/86 released as 5.6. --- war/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/war/pom.xml b/war/pom.xml index a91ea26e85da..da3705dd924c 100644 --- a/war/pom.xml +++ b/war/pom.xml @@ -99,7 +99,7 @@ THE SOFTWARE. --> org.jenkins-ci winstone - 5.6-20200103.213950-2 + 5.6 test From fafef5f7bc380423c684018b6e827673bb894d66 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Mon, 6 Jan 2020 15:58:49 -0500 Subject: [PATCH 51/59] Introduced constant for X-Remoting-Minimum-Version. --- core/src/main/java/jenkins/slaves/WebSocketAgents.java | 3 ++- .../main/resources/hudson/TcpSlaveAgentListener/index.jelly | 3 ++- pom.xml | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/jenkins/slaves/WebSocketAgents.java b/core/src/main/java/jenkins/slaves/WebSocketAgents.java index 03fb8972e784..c5bb49c5adda 100644 --- a/core/src/main/java/jenkins/slaves/WebSocketAgents.java +++ b/core/src/main/java/jenkins/slaves/WebSocketAgents.java @@ -34,6 +34,7 @@ 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; @@ -93,7 +94,7 @@ public HttpResponse doIndex(StaplerRequest req, StaplerResponse rsp) throws IOEx Capability remoteCapability = Capability.fromASCII(remoteCapabilityStr); LOGGER.fine(() -> "received " + remoteCapability); rsp.setHeader(Capability.KEY, new Capability().toASCII()); - rsp.setHeader("X-Remoting-Minimum-Version", RemotingVersionInfo.getMinimumSupportedVersion().toString()); + 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)); } 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/pom.xml b/pom.xml index 4ec28c67e636..341e4cf18583 100755 --- a/pom.xml +++ b/pom.xml @@ -102,7 +102,7 @@ THE SOFTWARE. 3.2.3 - 3.41-20200103.025016-1 + 3.41-20200106.205738-2 3.14 From afa60d89db74e5e2ed67c15832f65df0683e8340 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Mon, 6 Jan 2020 17:51:06 -0500 Subject: [PATCH 52/59] s/slave/agent/ in GUI --- .../main/resources/hudson/slaves/SlaveComputer/systemInfo.jelly | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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}

From cbca050bb673256453fe62b1b5216755a20eaa94 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Mon, 6 Jan 2020 18:05:00 -0500 Subject: [PATCH 53/59] Removing in-JVM test, as it was no longer useful after introducing shading anyway. --- .../jenkins/slaves/WebSocketAgentsTest.java | 73 ++++++------------- 1 file changed, 24 insertions(+), 49 deletions(-) diff --git a/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java b/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java index ab11aa1aca82..bfdd267d7af6 100644 --- a/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java +++ b/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java @@ -26,7 +26,6 @@ import hudson.Functions; import hudson.Proc; -import hudson.model.Computer; import hudson.model.FreeStyleProject; import hudson.model.Slave; import hudson.remoting.Engine; @@ -35,7 +34,6 @@ import hudson.slaves.SlaveComputer; import hudson.tasks.BatchFile; import hudson.tasks.Shell; -import hudson.triggers.SafeTimerTask; import java.io.File; import java.util.concurrent.atomic.AtomicReference; import java.util.logging.Level; @@ -74,61 +72,38 @@ public class WebSocketAgentsTest { @Rule public TemporaryFolder tmp = new TemporaryFolder(); - @FunctionalInterface - private interface TestLauncher { - void launch(String secret) throws Exception; - } - private void smokeTest(TestLauncher testLauncher) throws Exception { - 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(); - testLauncher.launch(secret); - 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); - } - - @Test - public void inJVM() throws Exception { - smokeTest(secret -> { - Computer.threadPoolForRemoting.submit(SafeTimerTask.of(() -> { - hudson.remoting.jnlp.Main._main(new String[] { - "-headless", - "-url", r.getURL().toString(), - "-webSocket", - "-workDir", tmp.newFolder("work").getAbsolutePath(), - secret, "remote"}); - })); - }); - } - /** - * More realistic version of {@link #inJVM}. - * On the other hand it is slower; requires {@code remoting} to have been {@code mvn install}ed; - * and does not show {@code FINE} or lower agent logs ({@link JenkinsRule#showAgentLogs(Slave, LoggerRule)} cannot be used here). + * 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 viaLauncher() throws Exception { + public void smokes() throws Exception { AtomicReference proc = new AtomicReference<>(); try { - smokeTest(secret -> { - 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()); - }); + 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(); From be59e0fdeae2bedd0f1172af0334d7be5a42f78a Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Mon, 6 Jan 2020 18:17:17 -0500 Subject: [PATCH 54/59] Bump. --- core/src/main/java/hudson/slaves/JNLPLauncher.java | 2 +- pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/hudson/slaves/JNLPLauncher.java b/core/src/main/java/hudson/slaves/JNLPLauncher.java index 7aec4b7cf209..db006ce8bd75 100644 --- a/core/src/main/java/hudson/slaves/JNLPLauncher.java +++ b/core/src/main/java/hudson/slaves/JNLPLauncher.java @@ -222,7 +222,7 @@ public FormValidation doCheckWebSocket(@QueryParameter boolean webSocket, @Query return FormValidation.error("WebSocket support is not enabled in this Jenkins installation"); } if (Util.fixEmptyAndTrim(tunnel) != null) { - return FormValidation.error("Tunneling is not currently supported in WebSocket mode"); + return FormValidation.error("Tunneling is not supported in WebSocket mode"); } } else { if (Jenkins.get().getTcpSlaveAgentListener() == null) { diff --git a/pom.xml b/pom.xml index 341e4cf18583..d58303e9afae 100755 --- a/pom.xml +++ b/pom.xml @@ -102,7 +102,7 @@ THE SOFTWARE. 3.2.3 - 3.41-20200106.205738-2 + 3.41-20200106.231421-3 3.14 From c339ac381fd583c0cebdb2178c3cfe4a3b9fc1d2 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Mon, 6 Jan 2020 18:18:42 -0500 Subject: [PATCH 55/59] No need to check for anonymous CONNECT here. --- core/src/main/java/jenkins/slaves/WebSocketAgents.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/jenkins/slaves/WebSocketAgents.java b/core/src/main/java/jenkins/slaves/WebSocketAgents.java index c5bb49c5adda..129f92e9f09c 100644 --- a/core/src/main/java/jenkins/slaves/WebSocketAgents.java +++ b/core/src/main/java/jenkins/slaves/WebSocketAgents.java @@ -78,7 +78,7 @@ public HttpResponse doIndex(StaplerRequest req, StaplerResponse rsp) throws IOEx 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))) { // TODO unless anonymous has CONNECT? + 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(); } From 8f415ffe422d7920787245f6d76f639d39c70d5f Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Mon, 6 Jan 2020 18:22:18 -0500 Subject: [PATCH 56/59] s/jenkins.slaves/jenkins.agents/g for new code. --- .../main/java/jenkins/{slaves => agents}/WebSocketAgents.java | 4 +++- .../java/jenkins/{slaves => agents}/WebSocketAgentsTest.java | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) rename core/src/main/java/jenkins/{slaves => agents}/WebSocketAgents.java (98%) rename test/src/test/java/jenkins/{slaves => agents}/WebSocketAgentsTest.java (99%) diff --git a/core/src/main/java/jenkins/slaves/WebSocketAgents.java b/core/src/main/java/jenkins/agents/WebSocketAgents.java similarity index 98% rename from core/src/main/java/jenkins/slaves/WebSocketAgents.java rename to core/src/main/java/jenkins/agents/WebSocketAgents.java index 129f92e9f09c..965c3b67eb8e 100644 --- a/core/src/main/java/jenkins/slaves/WebSocketAgents.java +++ b/core/src/main/java/jenkins/agents/WebSocketAgents.java @@ -22,7 +22,7 @@ * THE SOFTWARE. */ -package jenkins.slaves; +package jenkins.agents; import com.google.common.collect.ImmutableMap; import hudson.Extension; @@ -44,6 +44,8 @@ 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; diff --git a/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java b/test/src/test/java/jenkins/agents/WebSocketAgentsTest.java similarity index 99% rename from test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java rename to test/src/test/java/jenkins/agents/WebSocketAgentsTest.java index bfdd267d7af6..37c47dcfd388 100644 --- a/test/src/test/java/jenkins/slaves/WebSocketAgentsTest.java +++ b/test/src/test/java/jenkins/agents/WebSocketAgentsTest.java @@ -22,7 +22,7 @@ * THE SOFTWARE. */ -package jenkins.slaves; +package jenkins.agents; import hudson.Functions; import hudson.Proc; From b530633e90adb4298206c842129d812c66ec2e94 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Mon, 6 Jan 2020 18:36:39 -0500 Subject: [PATCH 57/59] Restrict the diagnostic endpoint to administrators. --- core/src/main/java/jenkins/websocket/WebSocketEcho.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/jenkins/websocket/WebSocketEcho.java b/core/src/main/java/jenkins/websocket/WebSocketEcho.java index 0071c8bdee75..5aa8b432c2f8 100644 --- a/core/src/main/java/jenkins/websocket/WebSocketEcho.java +++ b/core/src/main/java/jenkins/websocket/WebSocketEcho.java @@ -26,15 +26,16 @@ import hudson.Extension; import hudson.model.InvisibleAction; -import hudson.model.UnprotectedRootAction; +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 UnprotectedRootAction { +public class WebSocketEcho extends InvisibleAction implements RootAction { @Override public String getUrlName() { @@ -42,6 +43,7 @@ public String getUrlName() { } public HttpResponse doIndex() { + Jenkins.get().checkPermission(Jenkins.ADMINISTER); return WebSockets.upgrade(new WebSocketSession() { @Override protected void text(String message) { From fa713315a9460f6b90bb296176b72be4f4544bd9 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Tue, 7 Jan 2020 08:21:38 -0500 Subject: [PATCH 58/59] Fixed Javadoc import after package move. --- core/src/main/java/jenkins/slaves/JnlpAgentReceiver.java | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/main/java/jenkins/slaves/JnlpAgentReceiver.java b/core/src/main/java/jenkins/slaves/JnlpAgentReceiver.java index 3effde30df5e..20c5ed9f42e1 100644 --- a/core/src/main/java/jenkins/slaves/JnlpAgentReceiver.java +++ b/core/src/main/java/jenkins/slaves/JnlpAgentReceiver.java @@ -6,6 +6,7 @@ import hudson.model.Slave; import java.security.SecureRandom; import javax.annotation.Nonnull; +import jenkins.agents.WebSocketAgents; import org.jenkinsci.remoting.engine.JnlpClientDatabase; import org.jenkinsci.remoting.engine.JnlpConnectionStateListener; From fa2b19339a2886acc0d2d82321bdbe87d5adb536 Mon Sep 17 00:00:00 2001 From: Jesse Glick Date: Tue, 14 Jan 2020 17:51:27 -0500 Subject: [PATCH 59/59] https://github.com/jenkinsci/remoting/pull/357 released as 4.0. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 8c63182f58c6..f29a642ecdbc 100755 --- a/pom.xml +++ b/pom.xml @@ -101,7 +101,7 @@ THE SOFTWARE. 3.2.3 - 3.41-20200106.231421-3 + 4.0 3.14