提交 5b504029 编写于 作者: J Jesse Glick 提交者: Oleg Nenashev

[JEP-222] WebSocket support (#4369)

* Playing with WebSocket connections.

* Pluggable handlers.

* errorWithoutStack

* Server-side keepalive pings.

* Redesigned to work as an HttpResponse.

* Unused dep.

* Comment on JnlpSlaveRestarterInstaller.

* Sketch of WebSocket-based endpoint for JNLPLauncher.

* Comment.

* Test enhancement.

* Making it more obvious from JNLPLauncherTest where remoting.jar is being loaded from.

* Reworked protocol to negotiate remote capabilities.

* Unhelpful comment.

* Simplifying handshake to use HTTP headers.

* Moving code into top-level classes and otherwise prettifying.

* Linking to upstream PRs.

* Timestamped snapshot + incremental.

* Picking up incrementalified build of winstone.

* Working around https://github.com/kohsuke/access-modifier/pull/17.

* Finally have an incrementalified build of remoting.

* Use -webSocket option.

* Handling some close and error methods.

* Sending X-Remoting-Minimum-Version.

* If hudson.remoting.jnlp.Main._main fails, show the error.

* Comment.

* GUI analogue of https://github.com/jenkinsci/remoting/pull/357/commits/86cea5b83fd2fc93a9fc10896962eb42c95f80a0.

* Missing since tags.

* Capitalization.

* WebSockets.isSupported

* Rather than hiding JNLPLauncher.DescriptorImpl when the TCP port is disabled, always display it, but show form validation appropriate to WebSocket or TCP mode.

* Form validation fixes.

* Minor test improvements.

* Reworked WebSocketAgents to be compatible with JnlpAgentReceiver.

* Removing WebSocketSession.keepAlive in favor of a global setting applicable to all services.

* Add a -webSocket option to the Jenkins CLI.

* Using a snapshot deployment of Remoting, pending INFRA-2379.

* After #3838 there is no reason to recheck authentication after parsing CLICommand arguments.

* Advertise the -webSocket option.

* Adapt to newer HtmlUnit.

* https://github.com/HtmlUnit/htmlunit/pull/29 seems to have been incompatible.
WebClient.addRequestHeader will no longer override a header in a WebRequest,
such as when the same WebClient/WebRequest was previously used with different headers.

* Tracked down a behavioral change in passing through URL-encoded path characters.
https://github.com/HtmlUnit/htmlunit/commit/2c4956863420e4baef9d3d8c23ec0577ec64d2bf picks up
https://github.com/apache/httpcomponents-client/commit/8c04c6ae5e5ba1432e40684428338ce68431766b which is the actual change.

* Disabled TCP port does not matter in WebSocket mode.

* Shade dependencies needed for jenkins-cli.jar.

* Help text edit.

* https://github.com/jenkinsci/winstone/pull/79 was released as 5.5.

* https://github.com/jenkinsci/jenkins-test-harness/pull/183 released as 2.59.

* https://github.com/jenkinsci/jenkins/pull/4387#discussion_r362696234

* Bumping remoting to a new deployed snapshot.

* Need https://github.com/jenkinsci/winstone/pull/86.

* https://github.com/jenkinsci/winstone/pull/86 released as 5.6.

* Introduced constant for X-Remoting-Minimum-Version.

* s/slave/agent/ in GUI

* Removing in-JVM test, as it was no longer useful after introducing shading anyway.

* Bump.

* No need to check for anonymous CONNECT here.

* s/jenkins.slaves/jenkins.agents/g for new code.

* Restrict the diagnostic endpoint to administrators.

* Fixed Javadoc import after package move.

* https://github.com/jenkinsci/remoting/pull/357 released as 4.0.
上级 0dd7009a
......@@ -53,14 +53,17 @@
<dependency>
<groupId>org.kohsuke</groupId>
<artifactId>access-modifier-annotation</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jenkins-ci</groupId>
<artifactId>annotation-indexer</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>junit</groupId>
......@@ -71,32 +74,41 @@
<groupId>org.jvnet.localizer</groupId>
<artifactId>localizer</artifactId>
<version>1.26</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.apache.sshd</groupId>
<artifactId>sshd-core</artifactId>
<version>1.7.0</version>
<optional>true</optional> <!-- do not expose to core -->
<optional>true</optional>
</dependency>
<!-- ed25519 algorithm, see JENKINS-45318 -->
<dependency>
<groupId>net.i2p.crypto</groupId>
<artifactId>eddsa</artifactId>
<version>0.3.0</version>
<optional>true</optional>
</dependency>
<!-- ed25519 algorithm, see JENKINS-45318 -->
<dependency>
<groupId>net.i2p.crypto</groupId>
<artifactId>eddsa</artifactId>
<version>0.3.0</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-jdk14</artifactId>
<optional>true</optional> <!-- ditto -->
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.glassfish.tyrus.bundles</groupId>
<artifactId>tyrus-standalone-client-jdk</artifactId>
<version>1.12</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-annotations</artifactId>
<optional>true</optional>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>commons-lang</groupId>
<artifactId>commons-lang</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
......@@ -107,29 +119,43 @@
<version>2.22.2</version>
</plugin>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<!-- version specified in grandparent pom -->
<executions>
<execution>
<goals>
<goal>single</goal>
</goals>
<phase>package</phase>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<manifest>
<mainClass>hudson.cli.CLI</mainClass>
</manifest>
<manifestEntries>
<Jenkins-CLI-Version>${project.version}</Jenkins-CLI-Version>
</manifestEntries>
</archive>
</configuration>
</execution>
</executions>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.2.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<relocations>
<relocation>
<pattern>javax.websocket</pattern>
<shadedPattern>io.jenkins.cli.shaded.javax.websocket</shadedPattern>
</relocation>
<relocation>
<pattern>org</pattern>
<shadedPattern>io.jenkins.cli.shaded.org</shadedPattern>
</relocation>
<relocation>
<pattern>net</pattern>
<shadedPattern>io.jenkins.cli.shaded.net</shadedPattern>
</relocation>
</relocations>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>hudson.cli.CLI</mainClass>
<manifestEntries>
<Jenkins-CLI-Version>${project.version}</Jenkins-CLI-Version>
</manifestEntries>
</transformer>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jvnet.localizer</groupId>
......
......@@ -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.File;
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 class CLI {
}
}
private enum Mode {HTTP, SSH}
private enum Mode {HTTP, SSH, WEB_SOCKET}
public static int _main(String[] _args) throws Exception {
List<String> args = Arrays.asList(_args);
PrivateKeyProvider provider = new PrivateKeyProvider();
......@@ -101,8 +113,9 @@ public class CLI {
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 class CLI {
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 class CLI {
continue;
}
if (head.equals("-noKeyAuth")) {
tryLoadPKey = false;
noKeyAuth = true;
args = args.subList(1,args.size());
continue;
}
......@@ -229,9 +251,6 @@ public class CLI {
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 class CLI {
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 class CLI {
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 class CLI {
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<String> 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<String> 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<String, List<String>> 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<String> 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 class CLI {
}
}.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<String> 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() {
......
......@@ -37,6 +37,7 @@ import java.nio.channels.ReadPendingException;
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 @@ class PlainCLIProtocol {
}
}
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 @@ class PlainCLIProtocol {
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 @@ class PlainCLIProtocol {
@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 @@ class PlainCLIProtocol {
onStart();
return true;
case STDIN:
onStdin(readChunk(framelen));
onStdin(IOUtils.toByteArray(dis));
return true;
case END_STDIN:
onEndStdin();
......@@ -286,23 +311,22 @@ class PlainCLIProtocol {
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 @@ class PlainCLIProtocol {
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;
......
......@@ -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\
......
......@@ -27,6 +27,7 @@ package hudson.cli;
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 @@ public class PlainCLIProtocolTest {
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 @@ public class PlainCLIProtocolTest {
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 @@ public class PlainCLIProtocolTest {
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 @@ public class PlainCLIProtocolTest {
sendExit(2);
}
void newop() throws IOException {
DataOutputStream dos = new DataOutputStream(download);
dos.writeInt(0);
dos.writeByte(99);
dos.flush();
......@@ -117,8 +120,8 @@ public class PlainCLIProtocolTest {
}
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) {
......
......@@ -43,20 +43,26 @@ import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
import hudson.Extension;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.io.PrintStream;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.nio.charset.UnsupportedCharsetException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level;
import java.util.logging.Logger;
import jenkins.util.FullDuplexHttpService;
import jenkins.websocket.WebSocketSession;
import jenkins.websocket.WebSockets;
import org.acegisecurity.Authentication;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.HttpResponses;
/**
......@@ -100,6 +106,74 @@ public class CLIAction implements UnprotectedRootAction, StaplerProxy {
req.getView(this, "command.jelly").forward(req, rsp);
}
/** for Jelly */
public boolean isWebSocketSupported() {
return WebSockets.isSupported();
}
/**
* WebSocket endpoint.
*/
public HttpResponse doWs() {
if (!WebSockets.isSupported()) {
return HttpResponses.notFound();
}
Authentication authentication = Jenkins.getAuthentication();
return WebSockets.upgrade(new WebSocketSession() {
ServerSideImpl connection;
class OutputImpl implements PlainCLIProtocol.Output {
@Override
public void send(byte[] data) throws IOException {
sendBinary(ByteBuffer.wrap(data));
}
@Override
public void close() throws IOException {
doClose();
}
}
private void doClose() {
close();
}
@Override
protected void opened() {
try {
connection = new ServerSideImpl(new OutputImpl(), authentication);
} catch (IOException x) {
error(x);
return;
}
new Thread(() -> {
try {
try {
connection.run();
} finally {
connection.close();
}
} catch (Exception x) {
error(x);
}
}, "CLI handler for " + authentication.getName()).start();
}
@Override
protected void binary(byte[] payload, int offset, int len) {
try {
connection.handle(new DataInputStream(new ByteArrayInputStream(payload, offset, len)));
} catch (IOException x) {
error(x);
}
}
@Override
protected void error(Throwable cause) {
LOGGER.log(Level.WARNING, null, cause);
}
@Override
protected void closed(int statusCode, String reason) {
LOGGER.fine(() -> "closed: " + statusCode + ": " + reason);
connection.handleClose();
}
});
}
@Override
public Object getTarget() {
StaplerRequest req = Stapler.getCurrentRequest();
......@@ -116,6 +190,105 @@ public class CLIAction implements UnprotectedRootAction, StaplerProxy {
}
}
class ServerSideImpl extends PlainCLIProtocol.ServerSide {
private Thread runningThread;
private boolean ready;
private final List<String> args = new ArrayList<>();
private Locale locale = Locale.getDefault();
private Charset encoding = Charset.defaultCharset();
private final PipedInputStream stdin = new PipedInputStream();
private final PipedOutputStream stdinMatch = new PipedOutputStream();
private final Authentication authentication;
ServerSideImpl(PlainCLIProtocol.Output out, Authentication authentication) throws IOException {
super(out);
stdinMatch.connect(stdin);
this.authentication = authentication;
}
@Override
protected void onArg(String text) {
args.add(text);
}
@Override
protected void onLocale(String text) {
for (Locale _locale : Locale.getAvailableLocales()) {
if (_locale.toString().equals(text)) {
locale = _locale;
return;
}
}
LOGGER.log(Level.WARNING, "unknown client locale {0}", text);
}
@Override
protected void onEncoding(String text) {
try {
encoding = Charset.forName(text);
} catch (UnsupportedCharsetException x) {
LOGGER.log(Level.WARNING, "unknown client charset {0}", text);
}
}
@Override
protected void onStart() {
ready();
}
@Override
protected void onStdin(byte[] chunk) throws IOException {
stdinMatch.write(chunk);
}
@Override
protected void onEndStdin() throws IOException {
stdinMatch.close();
}
@Override
protected void handleClose() {
ready();
if (runningThread != null) {
runningThread.interrupt();
}
}
private synchronized void ready() {
ready = true;
notifyAll();
}
void run() throws IOException, InterruptedException {
synchronized (this) {
while (!ready) {
wait();
}
}
PrintStream stdout = new PrintStream(streamStdout(), false, encoding.name());
PrintStream stderr = new PrintStream(streamStderr(), true, encoding.name());
if (args.isEmpty()) {
stderr.println("Connection closed before arguments received");
sendExit(2);
return;
}
String commandName = args.get(0);
CLICommand command = CLICommand.clone(commandName);
if (command == null) {
stderr.println("No such command " + commandName);
sendExit(2);
return;
}
command.setTransportAuth(authentication);
command.setClientCharset(encoding);
CLICommand orig = CLICommand.setCurrent(command);
try {
runningThread = Thread.currentThread();
int exit = command.main(args.subList(1, args.size()), locale, stdin, stdout, stderr);
stdout.flush();
sendExit(exit);
try { // seems to avoid ReadPendingException from Jetty
Thread.sleep(1000);
} catch (InterruptedException x) {
// expected; ignore
}
} finally {
CLICommand.setCurrent(orig);
runningThread = null;
}
}
}
/**
* Serves {@link PlainCLIProtocol} response.
*/
......@@ -130,103 +303,9 @@ public class CLIAction implements UnprotectedRootAction, StaplerProxy {
return new FullDuplexHttpService(uuid) {
@Override
protected void run(InputStream upload, OutputStream download) throws IOException, InterruptedException {
final AtomicReference<Thread> runningThread = new AtomicReference<>();
class ServerSideImpl extends PlainCLIProtocol.ServerSide {
boolean ready;
List<String> 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();
}
}
};
......
......@@ -244,8 +244,6 @@ public abstract class CLICommand implements ExtensionPoint, Cloneable {
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();
......
......@@ -407,23 +407,19 @@ public abstract class Slave extends Node implements Serializable {
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;
}
......
......@@ -27,19 +27,21 @@ import hudson.Extension;
import hudson.Util;
import hudson.model.Computer;
import hudson.model.Descriptor;
import hudson.model.DescriptorVisibilityFilter;
import hudson.model.TaskListener;
import hudson.util.FormValidation;
import javax.annotation.CheckForNull;
import javax.annotation.Nonnull;
import jenkins.model.Jenkins;
import jenkins.slaves.RemotingWorkDirSettings;
import jenkins.util.java.JavaUtils;
import jenkins.websocket.WebSockets;
import org.jenkinsci.Symbol;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.DataBoundSetter;
import org.kohsuke.stapler.QueryParameter;
/**
* {@link ComputerLauncher} via inbound connections.
......@@ -72,6 +74,8 @@ public class JNLPLauncher extends ComputerLauncher {
@Nonnull
private RemotingWorkDirSettings workDirSettings = RemotingWorkDirSettings.getEnabledDefaults();
private boolean webSocket;
/**
* Constructor.
* @param tunnel Tunnel settings
......@@ -143,6 +147,21 @@ public class JNLPLauncher extends ComputerLauncher {
return false;
}
/**
* @since TODO
*/
public boolean isWebSocket() {
return webSocket;
}
/**
* @since TODO
*/
@DataBoundSetter
public void setWebSocket(boolean webSocket) {
this.webSocket = webSocket;
}
@Override
public void launch(SlaveComputer computer, TaskListener listener) {
// do nothing as we cannot self start
......@@ -196,25 +215,23 @@ public class JNLPLauncher extends ComputerLauncher {
// Causes JENKINS-45895 in the case of includes otherwise
return DescriptorImpl.class.equals(getClass());
}
}
/**
* Hides the JNLP launcher when the JNLP agent port is not enabled.
*
* @since 2.16
*/
@Extension
public static class DescriptorVisibilityFilterImpl extends DescriptorVisibilityFilter {
@Override
public boolean filter(@CheckForNull Object context, @Nonnull Descriptor descriptor) {
return descriptor.clazz != JNLPLauncher.class || Jenkins.get().getTcpSlaveAgentListener() != null;
public FormValidation doCheckWebSocket(@QueryParameter boolean webSocket, @QueryParameter String tunnel) {
if (webSocket) {
if (!WebSockets.isSupported()) {
return FormValidation.error("WebSocket support is not enabled in this Jenkins installation");
}
if (Util.fixEmptyAndTrim(tunnel) != null) {
return FormValidation.error("Tunneling is not supported in WebSocket mode");
}
} else {
if (Jenkins.get().getTcpSlaveAgentListener() == null) {
return FormValidation.error("Either WebSocket mode is selected, or the TCP port for inbound agents must be enabled");
}
}
return FormValidation.ok();
}
@Override
public boolean filterType(@Nonnull Class<?> contextClass, @Nonnull Descriptor descriptor) {
return descriptor.clazz != JNLPLauncher.class || Jenkins.get().getTcpSlaveAgentListener() != null;
}
}
/**
......
......@@ -50,6 +50,27 @@ import jenkins.util.Timer;
*/
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.
* <p><strong>Beware: changing it while Jenkins is running gives no guarantee logs will be sent to the new location
......
/*
* The MIT License
*
* Copyright 2019 CloudBees, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package jenkins.agents;
import com.google.common.collect.ImmutableMap;
import hudson.Extension;
import hudson.ExtensionList;
import hudson.model.Computer;
import hudson.model.InvisibleAction;
import hudson.model.UnprotectedRootAction;
import hudson.remoting.AbstractByteArrayCommandTransport;
import hudson.remoting.Capability;
import hudson.remoting.Channel;
import hudson.remoting.ChannelBuilder;
import hudson.remoting.Engine;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedChannelException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.Collections;
import java.util.logging.Level;
import java.util.logging.Logger;
import jenkins.slaves.JnlpAgentReceiver;
import jenkins.slaves.RemotingVersionInfo;
import jenkins.websocket.WebSocketSession;
import jenkins.websocket.WebSockets;
import org.jenkinsci.remoting.engine.JnlpConnectionState;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.HttpResponses;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
@Extension
@Restricted(NoExternalUse.class)
public final class WebSocketAgents extends InvisibleAction implements UnprotectedRootAction {
private static final Logger LOGGER = Logger.getLogger(WebSocketAgents.class.getName());
@Override
public String getUrlName() {
return WebSockets.isSupported() ? "wsagents" : null;
}
public HttpResponse doIndex(StaplerRequest req, StaplerResponse rsp) throws IOException {
String agent = req.getHeader(JnlpConnectionState.CLIENT_NAME_KEY);
String secret = req.getHeader(JnlpConnectionState.SECRET_KEY);
String remoteCapabilityStr = req.getHeader(Capability.KEY);
if (agent == null || secret == null || remoteCapabilityStr == null) {
LOGGER.warning(() -> "incomplete headers: " + Collections.list(req.getHeaderNames()));
throw HttpResponses.errorWithoutStack(400, "This endpoint is only for use from agent.jar in WebSocket mode");
}
LOGGER.fine(() -> "receiving headers: " + Collections.list(req.getHeaderNames()));
if (!JnlpAgentReceiver.DATABASE.exists(agent)) {
LOGGER.warning(() -> "no such agent " + agent);
throw HttpResponses.errorWithoutStack(400, "no such agent");
}
if (!MessageDigest.isEqual(secret.getBytes(StandardCharsets.US_ASCII), JnlpAgentReceiver.DATABASE.getSecretOf(agent).getBytes(StandardCharsets.US_ASCII))) {
LOGGER.warning(() -> "incorrect secret for " + agent);
throw HttpResponses.forbidden();
}
JnlpConnectionState state = new JnlpConnectionState(null, ExtensionList.lookup(JnlpAgentReceiver.class));
state.setRemoteEndpointDescription(req.getRemoteAddr());
state.fireBeforeProperties();
LOGGER.fine(() -> "connecting " + agent);
state.fireAfterProperties(ImmutableMap.of(
// TODO or just pass all request headers?
JnlpConnectionState.CLIENT_NAME_KEY, agent,
JnlpConnectionState.SECRET_KEY, secret
));
Capability remoteCapability = Capability.fromASCII(remoteCapabilityStr);
LOGGER.fine(() -> "received " + remoteCapability);
rsp.setHeader(Capability.KEY, new Capability().toASCII());
rsp.setHeader(Engine.REMOTING_MINIMUM_VERSION_HEADER, RemotingVersionInfo.getMinimumSupportedVersion().toString());
rsp.setHeader(JnlpConnectionState.COOKIE_KEY, JnlpAgentReceiver.generateCookie()); // TODO figure out what this is for, if anything
return WebSockets.upgrade(new Session(state, agent, remoteCapability));
}
private static class Session extends WebSocketSession {
private final JnlpConnectionState state;
private final String agent;
private final Capability remoteCapability;
private AbstractByteArrayCommandTransport.ByteArrayReceiver receiver;
Session(JnlpConnectionState state, String agent, Capability remoteCapability) {
this.state = state;
this.agent = agent;
this.remoteCapability = remoteCapability;
}
@Override
protected void opened() {
Computer.threadPoolForRemoting.submit(() -> {
LOGGER.fine(() -> "setting up channel for " + agent);
state.fireBeforeChannel(new ChannelBuilder(agent, Computer.threadPoolForRemoting));
state.fireAfterChannel(state.getChannelBuilder().build(new Transport()));
LOGGER.fine(() -> "set up channel for " + agent);
return null;
});
}
@Override
protected void binary(byte[] payload, int offset, int len) {
LOGGER.finest(() -> "reading block of length " + len + " from " + agent);
if (offset == 0 && len == payload.length) {
receiver.handle(payload);
} else {
receiver.handle(Arrays.copyOfRange(payload, offset, offset + len));
}
}
@Override
protected void closed(int statusCode, String reason) {
LOGGER.finest(() -> "closed " + statusCode + " " + reason);
IOException x = new ClosedChannelException();
receiver.terminate(x);
state.fireChannelClosed(x);
state.fireAfterDisconnect();
}
@Override
protected void error(Throwable cause) {
LOGGER.log(Level.WARNING, null, cause);
}
class Transport extends AbstractByteArrayCommandTransport {
@Override
public void setup(AbstractByteArrayCommandTransport.ByteArrayReceiver bar) {
receiver = bar;
}
@Override
public void writeBlock(Channel chnl, byte[] bytes) throws IOException {
LOGGER.finest(() -> "writing block of length " + bytes.length + " to " + agent);
try {
sendBinary(ByteBuffer.wrap(bytes)).get();
} catch (Exception x) {
x.printStackTrace();
throw new IOException(x);
}
}
@Override
public Capability getRemoteCapability() throws IOException {
return remoteCapability;
}
@Override
public void closeWrite() throws IOException {
LOGGER.finest(() -> "closeWrite");
close();
}
@Override
public void closeRead() throws IOException {
LOGGER.finest(() -> "closeRead");
close();
}
}
}
}
......@@ -113,7 +113,7 @@ public class DefaultJnlpSlaveReceiver extends JnlpAgentReceiver {
+ "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 class DefaultJnlpSlaveReceiver extends JnlpAgentReceiver {
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);
}
......
......@@ -7,12 +7,13 @@ import hudson.model.Slave;
import java.security.SecureRandom;
import javax.annotation.Nonnull;
import jenkins.agents.WebSocketAgents;
import jenkins.security.HMACConfidentialKey;
import org.jenkinsci.remoting.engine.JnlpClientDatabase;
import org.jenkinsci.remoting.engine.JnlpConnectionStateListener;
/**
* Receives incoming agents connecting through {@link JnlpSlaveAgentProtocol4}.
* Receives incoming agents connecting through the likes of {@link JnlpSlaveAgentProtocol4} or {@link WebSocketAgents}.
*
* <p>
* This is useful to establish the communication with other JVMs and use them
......
/*
* The MIT License
*
* Copyright 2019 CloudBees, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package jenkins.websocket;
import hudson.Extension;
import hudson.model.InvisibleAction;
import hudson.model.RootAction;
import java.nio.ByteBuffer;
import jenkins.model.Jenkins;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.kohsuke.stapler.HttpResponse;
@Extension
@Restricted(NoExternalUse.class)
public class WebSocketEcho extends InvisibleAction implements RootAction {
@Override
public String getUrlName() {
return "wsecho";
}
public HttpResponse doIndex() {
Jenkins.get().checkPermission(Jenkins.ADMINISTER);
return WebSockets.upgrade(new WebSocketSession() {
@Override
protected void text(String message) {
sendText("hello " + message);
}
@Override
protected void binary(byte[] payload, int offset, int len) {
ByteBuffer data = ByteBuffer.allocate(len);
for (int i = 0; i < len; i++) {
byte b = payload[offset + i];
if (b >= 'a' && b <= 'z') {
b += 'A' - 'a';
}
data.put(i, b);
}
sendBinary(data);
}
});
}
}
/*
* The MIT License
*
* Copyright 2019 CloudBees, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package jenkins.websocket;
import java.lang.reflect.Method;
import java.nio.ByteBuffer;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;
import jenkins.util.SystemProperties;
import jenkins.util.Timer;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.Beta;
/**
* One WebSocket connection.
* @see WebSockets
* @since TODO
*/
@Restricted(Beta.class)
public abstract class WebSocketSession {
/**
* Number of seconds between server-sent pings.
* Zero to disable.
* <p><a href="http://nginx.org/en/docs/http/websocket.html">nginx docs</a> claim 60s timeout and this seems to match experiments.
* <a href="https://cloud.google.com/kubernetes-engine/docs/concepts/ingress#support_for_websocket">GKE docs</a> says 30s
* but this is a total timeout, not inactivity, so you need to set {@code BackendConfigSpec.timeoutSec} anyway.
* <p>This is set for the whole Jenkins session rather than a particular service,
* since it has more to do with the environment than anything else.
* Certain services may have their own “keep alive” semantics,
* but for example {@link hudson.remoting.PingThread} may be too infrequent.
*/
private static long PING_INTERVAL_SECONDS = SystemProperties.getLong("jenkins.websocket.pingInterval", 30L);
private static final Logger LOGGER = Logger.getLogger(WebSocketSession.class.getName());
private Object session;
private Object remoteEndpoint;
private ScheduledFuture<?> pings;
protected WebSocketSession() {}
Object onWebSocketSomething(Object proxy, Method method, Object[] args) throws Exception {
switch (method.getName()) {
case "onWebSocketConnect":
this.session = args[0];
this.remoteEndpoint = session.getClass().getMethod("getRemote").invoke(args[0]);
if (PING_INTERVAL_SECONDS != 0) {
pings = Timer.get().scheduleAtFixedRate(() -> {
try {
remoteEndpoint.getClass().getMethod("sendPing", ByteBuffer.class).invoke(remoteEndpoint, ByteBuffer.wrap(new byte[0]));
} catch (Exception x) {
error(x);
pings.cancel(true);
}
}, PING_INTERVAL_SECONDS / 2, PING_INTERVAL_SECONDS, TimeUnit.SECONDS);
}
opened();
return null;
case "onWebSocketClose":
if (pings != null) {
pings.cancel(true);
// alternately, check Session.isOpen each time
}
closed((Integer) args[0], (String) args[1]);
return null;
case "onWebSocketError":
error((Throwable) args[0]);
return null;
case "onWebSocketBinary":
binary((byte[]) args[0], (Integer) args[1], (Integer) args[2]);
return null;
case "onWebSocketText":
text((String) args[0]);
return null;
default:
throw new AssertionError();
}
}
protected void opened() {
}
protected void closed(int statusCode, String reason) {
}
protected void error(Throwable cause) {
LOGGER.log(Level.WARNING, "unhandled WebSocket service error", cause);
}
protected void binary(byte[] payload, int offset, int len) {
LOGGER.warning("unexpected binary frame");
}
protected void text(String message) {
LOGGER.warning("unexpected text frame");
}
@SuppressWarnings("unchecked")
protected final Future<Void> sendBinary(ByteBuffer data) {
try {
return (Future<Void>) remoteEndpoint.getClass().getMethod("sendBytesByFuture", ByteBuffer.class).invoke(remoteEndpoint, data);
} catch (Exception x) {
throw new RuntimeException(x);
}
}
@SuppressWarnings("unchecked")
protected final Future<Void> sendText(String text) {
try {
return (Future<Void>) remoteEndpoint.getClass().getMethod("sendStringByFuture", String.class).invoke(remoteEndpoint, text);
} catch (Exception x) {
throw new RuntimeException(x);
}
}
protected final void close() {
try {
session.getClass().getMethod("close").invoke(session);
} catch (Exception x) {
throw new RuntimeException(x);
}
}
}
/*
* The MIT License
*
* Copyright 2019 CloudBees, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package jenkins.websocket;
import hudson.Extension;
import hudson.ExtensionList;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.Beta;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.HttpResponses;
import org.kohsuke.stapler.Stapler;
/**
* Support for serving WebSocket responses.
* @since TODO
*/
@Restricted(Beta.class)
@Extension
public class WebSockets {
private static final Logger LOGGER = Logger.getLogger(WebSockets.class.getName());
private static final String ATTR_SESSION = WebSockets.class.getName() + ".session";
// TODO ability to handle subprotocols?
public static HttpResponse upgrade(WebSocketSession session) {
return (req, rsp, node) -> {
try {
Object factory = ExtensionList.lookupSingleton(WebSockets.class).init();
if (!((Boolean) webSocketServletFactoryClass.getMethod("isUpgradeRequest", HttpServletRequest.class, HttpServletResponse.class).invoke(factory, req, rsp))) {
throw HttpResponses.errorWithoutStack(HttpServletResponse.SC_BAD_REQUEST, "only WS connections accepted here");
}
req.setAttribute(ATTR_SESSION, session);
if (!((Boolean) webSocketServletFactoryClass.getMethod("acceptWebSocket", HttpServletRequest.class, HttpServletResponse.class).invoke(factory, req, rsp))) {
throw HttpResponses.errorWithoutStack(HttpServletResponse.SC_BAD_REQUEST, "did not manage to upgrade");
}
} catch (HttpResponses.HttpResponseException x) {
throw x;
} catch (Exception x) {
LOGGER.log(Level.WARNING, null, x);
throw HttpResponses.error(x);
}
// OK!
};
}
private static ClassLoader cl;
private static Class<?> webSocketServletFactoryClass;
private static synchronized void staticInit() throws Exception {
if (webSocketServletFactoryClass == null) {
cl = ServletContext.class.getClassLoader();
webSocketServletFactoryClass = cl.loadClass("org.eclipse.jetty.websocket.servlet.WebSocketServletFactory");
}
}
public static boolean isSupported() {
try {
staticInit();
return true;
} catch (Exception x) {
LOGGER.log(Level.FINE, null, x);
return false;
}
}
private /*WebSocketServletFactory*/Object factory;
private synchronized Object init() throws Exception {
if (factory == null) {
staticInit();
Class<?> webSocketPolicyClass = cl.loadClass("org.eclipse.jetty.websocket.api.WebSocketPolicy");
factory = cl.loadClass("org.eclipse.jetty.websocket.servlet.WebSocketServletFactory$Loader").getMethod("load", ServletContext.class, webSocketPolicyClass).invoke(null, Stapler.getCurrent().getServletContext(), webSocketPolicyClass.getMethod("newServerPolicy").invoke(null));
webSocketServletFactoryClass.getMethod("start").invoke(factory);
Class<?> webSocketCreatorClass = cl.loadClass("org.eclipse.jetty.websocket.servlet.WebSocketCreator");
webSocketServletFactoryClass.getMethod("setCreator", webSocketCreatorClass).invoke(factory, Proxy.newProxyInstance(cl, new Class<?>[] {webSocketCreatorClass}, this::createWebSocket));
}
return factory;
}
private Object createWebSocket(Object proxy, Method method, Object[] args) throws Exception {
Object servletUpgradeRequest = args[0];
WebSocketSession session = (WebSocketSession) servletUpgradeRequest.getClass().getMethod("getServletAttribute", String.class).invoke(servletUpgradeRequest, ATTR_SESSION);
return Proxy.newProxyInstance(cl, new Class<?>[] {cl.loadClass("org.eclipse.jetty.websocket.api.WebSocketListener")}, session::onWebSocketSomething);
}
}
......@@ -40,7 +40,8 @@ THE SOFTWARE.
<!-- reduce the number of client connection attempts during protocol negotiation -->
<st:header name="X-Jenkins-Agent-Protocols" value="${app.tcpSlaveAgentListener.agentProtocolNames}"/>
<!-- publish minimum supported version of remoting agent -->
<st:header name="X-Remoting-Minimum-Version" value="${app.tcpSlaveAgentListener.remotingMinimumVersion}"/>
<j:getStatic var="rmvh" className="hudson.remoting.Engine" field="REMOTING_MINIMUM_VERSION_HEADER"/>
<st:header name="${rmvh}" value="${app.tcpSlaveAgentListener.remotingMinimumVersion}"/>
Jenkins
</j:jelly>
......@@ -34,6 +34,6 @@ THE SOFTWARE.
}
</style>
</st:once>
<pre id="example">java -jar <a href="${rootURL}/jnlpJars/jenkins-cli.jar" style="color: white">jenkins-cli.jar</a> -s ${h.inferHudsonURL(request)} ${commandArgs}</pre>
<pre id="example">java -jar <a href="${rootURL}/jnlpJars/jenkins-cli.jar" style="color: white">jenkins-cli.jar</a> -s ${h.inferHudsonURL(request)} <j:if test="${it.webSocketSupported}">-webSocket</j:if> ${commandArgs}</pre>
</j:jelly>
......@@ -28,6 +28,9 @@ THE SOFTWARE.
<j:if test="${descriptor.workDirSupported}">
<f:property title="${%Enable work directory}" field="workDirSettings" />
</j:if>
<f:entry field="webSocket">
<f:checkbox title="${%Use WebSocket}"/>
</f:entry>
<f:advanced>
<f:entry title="${%Tunnel connection through}" help="/help/system-config/master-slave/jnlp-tunnel.html">
<f:textbox field="tunnel"/>
......
<div>
Use WebSocket to connect to the Jenkins master rather than the TCP port.
See <a href="https://jenkins.io/jep/222">JEP-222</a> for background.
</div>
......@@ -25,7 +25,7 @@ THE SOFTWARE.
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define" xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form" xmlns:i="jelly:fmt">
<j:choose>
<j:when test="${app.slaveAgentPort==-1}">
<j:when test="${!it.launcher.webSocket and app.slaveAgentPort == -1}">
<div class="error">
${%slaveAgentPort.disabled}
<l:isAdmin><a href="${rootURL}/configureSecurity">${%configure.link.text}</a>.</l:isAdmin>
......
......@@ -63,6 +63,9 @@ THE SOFTWARE.
<application-desc main-class="hudson.remoting.jnlp.Main">
<argument>${it.jnlpMac}</argument>
<argument>${it.node.nodeName}</argument>
<j:if test="${launcher.webSocket}">
<argument>-webSocket</argument>
</j:if>
<j:if test="${launcher.tunnel!=null}">
<argument>-tunnel</argument>
<argument>${launcher.tunnel}</argument>
......
......@@ -45,7 +45,7 @@ THE SOFTWARE.
<l:hasPermission permission="${it.CONNECT}">
<j:choose>
<j:when test="${it.channel != null}">
<h2>${it.oSDescription} slave, version ${it.slaveVersion}</h2>
<h2>${it.oSDescription} agent, version ${it.slaveVersion}</h2>
<j:forEach var="instance" items="${it.systemInfoExtensions}">
<h1>${instance.displayName}</h1>
......
......@@ -101,7 +101,7 @@ THE SOFTWARE.
<maven-war-plugin.version>3.2.3</maven-war-plugin.version>
<!-- Bundled Remoting version -->
<remoting.version>3.40</remoting.version>
<remoting.version>4.0</remoting.version>
<!-- Minimum Remoting version, which is tested for API compatibility -->
<remoting.minimum.supported.version>3.14</remoting.minimum.supported.version>
......
......@@ -70,7 +70,7 @@ THE SOFTWARE.
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>jenkins-test-harness</artifactId>
<version>2.58</version>
<version>2.59</version>
<scope>test</scope>
<exclusions>
<exclusion>
......
......@@ -20,7 +20,6 @@ import java.lang.reflect.Field;
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 class CLIActionTest {
// @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 class CLIActionTest {
private static final String ADMIN = "admin@mycorp.com";
private void assertExitCode(int code, boolean useApiToken, File jar, String... args) throws IOException, InterruptedException {
List<String> commands = Lists.newArrayList("java", "-jar", jar.getAbsolutePath(), "-s", j.getURL().toString(), /* not covering SSH keys in this test */ "-noKeyAuth");
List<String> 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 class CLIActionTest {
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 class CLIActionTest {
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 class CLIActionTest {
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);
......
......@@ -157,6 +157,7 @@ public class CLITest {
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 class CLITest {
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 class CLITest {
assertNull(rsp.getContentAsString(), rsp.getResponseHeaderValue("X-Jenkins-CLI-Port"));
assertNull(rsp.getContentAsString(), rsp.getResponseHeaderValue("X-SSH-Endpoint"));
for (String transport: Arrays.asList("-http", "-ssh")) {
for (String transport: Arrays.asList("-http", "-ssh", "-webSocket")) {
String url = r.getURL().toString() + "cli-proxy/";
ByteArrayOutputStream baos = new ByteArrayOutputStream();
......
......@@ -56,9 +56,11 @@ import static org.junit.Assert.assertThat;
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 @@ import org.jvnet.hudson.test.recipes.LocalData;
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.
......
/*
* The MIT License
*
* Copyright 2019 CloudBees, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package jenkins.agents;
import hudson.Functions;
import hudson.Proc;
import hudson.model.FreeStyleProject;
import hudson.model.Slave;
import hudson.remoting.Engine;
import hudson.slaves.DumbSlave;
import hudson.slaves.JNLPLauncher;
import hudson.slaves.SlaveComputer;
import hudson.tasks.BatchFile;
import hudson.tasks.Shell;
import java.io.File;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level;
import java.util.logging.Logger;
import jenkins.security.SlaveToMasterCallable;
import org.apache.commons.io.FileUtils;
import org.apache.tools.ant.util.JavaEnvUtils;
import org.junit.ClassRule;
import org.junit.Test;
import static org.junit.Assert.*;
import org.junit.Rule;
import org.junit.rules.TemporaryFolder;
import org.jvnet.hudson.test.BuildWatcher;
import org.jvnet.hudson.test.Issue;
import org.jvnet.hudson.test.JenkinsRule;
import org.jvnet.hudson.test.LoggerRule;
@Issue("JEP-222")
public class WebSocketAgentsTest {
private static final Logger LOGGER = Logger.getLogger(WebSocketAgentsTest.class.getName());
@ClassRule
public static BuildWatcher buildWatcher = new BuildWatcher();
@Rule
public JenkinsRule r = new JenkinsRule();
@Rule
public LoggerRule logging = new LoggerRule().
record(Slave.class, Level.FINE).
record(SlaveComputer.class, Level.FINEST).
record(WebSocketAgents.class, Level.FINEST).
record(Engine.class, Level.FINEST);
@Rule
public TemporaryFolder tmp = new TemporaryFolder();
/**
* Verify basic functionality of an agent in {@code -webSocket} mode.
* Requires {@code remoting} to have been {@code mvn install}ed.
* Does not show {@code FINE} or lower agent logs ({@link JenkinsRule#showAgentLogs(Slave, LoggerRule)} cannot be used here).
* Unlike {@link hudson.slaves.JNLPLauncherTest} this does not use {@code javaws};
* closer to {@link hudson.bugs.JnlpAccessWithSecuredHudsonTest}.
* @see hudson.remoting.Launcher
*/
@SuppressWarnings("ResultOfMethodCallIgnored")
@Test
public void smokes() throws Exception {
AtomicReference<Proc> proc = new AtomicReference<>();
try {
JNLPLauncher launcher = new JNLPLauncher(true);
launcher.setWebSocket(true);
DumbSlave s = new DumbSlave("remote", tmp.newFolder("agent").getAbsolutePath(), launcher);
r.jenkins.addNode(s);
String secret = ((SlaveComputer) s.toComputer()).getJnlpMac();
File slaveJar = tmp.newFile();
FileUtils.copyURLToFile(new Slave.JnlpJar("slave.jar").getURL(), slaveJar);
proc.set(r.createLocalLauncher().launch().cmds(
JavaEnvUtils.getJreExecutable("java"), "-jar", slaveJar.getAbsolutePath(),
"-jnlpUrl", r.getURL() + "computer/remote/slave-agent.jnlp",
"-secret", secret
).stdout(System.out).start());
r.waitOnline(s);
assertEquals("response", s.getChannel().call(new DummyTask()));
FreeStyleProject p = r.createFreeStyleProject();
p.setAssignedNode(s);
p.getBuildersList().add(Functions.isWindows() ? new BatchFile("echo hello") : new Shell("echo hello"));
r.buildAndAssertSuccess(p);
s.toComputer().getLogText().writeLogTo(0, System.out);
} finally {
if (proc.get() != null) {
proc.get().kill();
while (r.jenkins.getComputer("remote").isOnline()) {
LOGGER.info("waiting for computer to go offline");
Thread.sleep(250);
}
}
}
}
private static class DummyTask extends SlaveToMasterCallable<String, RuntimeException> {
@Override
public String call() {
return "response";
}
}
}
......@@ -91,9 +91,7 @@ THE SOFTWARE.
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>cli</artifactId>
<classifier>jar-with-dependencies</classifier>
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<!--
......@@ -101,7 +99,7 @@ THE SOFTWARE.
-->
<groupId>org.jenkins-ci</groupId>
<artifactId>winstone</artifactId>
<version>5.4</version>
<version>5.6</version>
<scope>test</scope>
</dependency>
<dependency>
......@@ -262,13 +260,6 @@ THE SOFTWARE.
<configuration>
<artifactItems>
<!-- dependencies that goes to unusual locations -->
<artifactItem>
<groupId>${project.groupId}</groupId>
<artifactId>cli</artifactId>
<classifier>jar-with-dependencies</classifier>
<outputDirectory>${project.build.directory}/${project.build.finalName}/WEB-INF</outputDirectory>
<destFileName>jenkins-cli.jar</destFileName>
</artifactItem>
<artifactItem>
<groupId>org.jenkins-ci</groupId>
<artifactId>winstone</artifactId>
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册