diff --git a/cli/pom.xml b/cli/pom.xml index 42955c8b351879181e91b949d83592e608c7eacc..9d8fe693390cbe5443c27ea1a2385361784ba345 100644 --- a/cli/pom.xml +++ b/cli/pom.xml @@ -34,6 +34,10 @@ commons-codec 1.4 + + commons-io + commons-io + ${project.groupId} remoting @@ -50,6 +54,17 @@ 1.24 + org.apache.sshd + sshd-core + 1.2.0 + true + + + org.slf4j + slf4j-jdk14 + true + + org.jenkins-ci trilead-ssh2 build214-jenkins-1 diff --git a/cli/src/main/java/hudson/cli/CLI.java b/cli/src/main/java/hudson/cli/CLI.java index 4df7c421b6c9d81f8751a3cb716f09ed76d01bbe..c9d7149ca3d4cb9cb4bcf5b4520254080949d661 100644 --- a/cli/src/main/java/hudson/cli/CLI.java +++ b/cli/src/main/java/hudson/cli/CLI.java @@ -32,7 +32,6 @@ import hudson.remoting.RemoteInputStream; import hudson.remoting.RemoteOutputStream; import hudson.remoting.SocketChannelStream; import hudson.remoting.SocketOutputStream; - import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import javax.net.ssl.HostnameVerifier; @@ -59,6 +58,7 @@ import java.net.InetSocketAddress; import java.net.Socket; import java.net.URL; import java.net.URLConnection; +import java.nio.charset.Charset; import java.security.GeneralSecurityException; import java.security.KeyPair; import java.security.PublicKey; @@ -72,11 +72,11 @@ import java.util.Locale; import java.util.Properties; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.logging.ConsoleHandler; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.Logger; import static java.util.logging.Level.*; +import org.apache.commons.io.FileUtils; /** * CLI entry point to Jenkins. @@ -92,6 +92,11 @@ public class CLI implements AutoCloseable { private final String httpsProxyTunnel; private final String authorization; + /** + * For tests only. + * @deprecated Specific to {@link Mode#REMOTING}. + */ + @Deprecated public CLI(URL jenkins) throws IOException, InterruptedException { this(jenkins,null); } @@ -113,26 +118,27 @@ public class CLI implements AutoCloseable { public CLI(URL jenkins, ExecutorService exec, String httpsProxyTunnel) throws IOException, InterruptedException { this(new CLIConnectionFactory().url(jenkins).executorService(exec).httpsProxyTunnel(httpsProxyTunnel)); } - + + /** + * @deprecated Specific to {@link Mode#REMOTING}. + */ + @Deprecated /*package*/ CLI(CLIConnectionFactory factory) throws IOException, InterruptedException { URL jenkins = factory.jenkins; this.httpsProxyTunnel = factory.httpsProxyTunnel; this.authorization = factory.authorization; ExecutorService exec = factory.exec; - String url = jenkins.toExternalForm(); - if(!url.endsWith("/")) url+='/'; - ownsPool = exec==null; pool = exec!=null ? exec : Executors.newCachedThreadPool(new NamingThreadFactory(Executors.defaultThreadFactory(), "CLI.pool")); Channel _channel; try { - _channel = connectViaCliPort(jenkins, getCliTcpPort(url)); + _channel = connectViaCliPort(jenkins, getCliTcpPort(jenkins)); } catch (IOException e) { - LOGGER.log(Level.FINE,"Failed to connect via CLI port. Falling back to HTTP",e); + LOGGER.log(Level.FINE, "Failed to connect via CLI port. Falling back to HTTP", e); try { - _channel = connectViaHttp(url); + _channel = connectViaHttp(jenkins); } catch (IOException e2) { e.addSuppressed(e2); throw e; @@ -147,13 +153,15 @@ public class CLI implements AutoCloseable { throw new IOException(Messages.CLI_VersionMismatch()); } - private Channel connectViaHttp(String url) throws IOException { - LOGGER.log(FINE, "Trying to connect to {0} via HTTP", url); - url+="cli"; - URL jenkins = new URL(url); + /** + * @deprecated Specific to {@link Mode#REMOTING}. + */ + @Deprecated + private Channel connectViaHttp(URL url) throws IOException { + LOGGER.log(FINE, "Trying to connect to {0} via Remoting over HTTP", url); - FullDuplexHttpStream con = new FullDuplexHttpStream(jenkins,authorization); - Channel ch = new Channel("Chunked connection to "+jenkins, + FullDuplexHttpStream con = new FullDuplexHttpStream(url, "cli?remoting=true", authorization); + Channel ch = new Channel("Chunked connection to " + url, pool,con.getInputStream(),con.getOutputStream()); final long interval = 15*1000; final long timeout = (interval * 3) / 4; @@ -166,8 +174,17 @@ public class CLI implements AutoCloseable { return ch; } + /** + * @deprecated Specific to {@link Mode#REMOTING}. + */ + @Deprecated private Channel connectViaCliPort(URL jenkins, CliPort clip) throws IOException { - LOGGER.log(FINE, "Trying to connect directly via TCP/IP to {0}", clip.endpoint); + LOGGER.log(FINE, "Trying to connect directly via Remoting over TCP/IP to {0}", clip.endpoint); + + if (authorization != null) { + LOGGER.warning("-auth ignored when using JNLP agent port"); + } + final Socket s = new Socket(); // this prevents a connection from silently terminated by the router in between or the other peer // and that goes without unnoticed. However, the time out is often very long (for example 2 hours @@ -265,13 +282,14 @@ public class CLI implements AutoCloseable { /** * If the server advertises CLI endpoint, returns its location. + * @deprecated Specific to {@link Mode#REMOTING}. */ - protected CliPort getCliTcpPort(String url) throws IOException { - URL _url = new URL(url); - if (_url.getHost()==null || _url.getHost().length()==0) { + @Deprecated + protected CliPort getCliTcpPort(URL url) throws IOException { + if (url.getHost()==null || url.getHost().length()==0) { throw new IOException("Invalid URL: "+url); } - URLConnection head = _url.openConnection(); + URLConnection head = url.openConnection(); try { head.connect(); } catch (IOException e) { @@ -385,12 +403,6 @@ public class CLI implements AutoCloseable { } public static void main(final String[] _args) throws Exception { - Logger l = Logger.getLogger(ROOT_LOGGER_NAME); - l.setLevel(SEVERE); - ConsoleHandler h = new ConsoleHandler(); - h.setLevel(SEVERE); - l.addHandler(h); - try { System.exit(_main(_args)); } catch (Throwable t) { @@ -400,6 +412,7 @@ public class CLI implements AutoCloseable { } } + private enum Mode {HTTP, SSH, REMOTING} public static int _main(String[] _args) throws Exception { List args = Arrays.asList(_args); PrivateKeyProvider provider = new PrivateKeyProvider(); @@ -413,19 +426,52 @@ public class CLI implements AutoCloseable { boolean tryLoadPKey = true; + Mode mode = null; + + String user = null; + String auth = null; + boolean strictHostKey = false; + while(!args.isEmpty()) { String head = args.get(0); if (head.equals("-version")) { System.out.println("Version: "+computeVersion()); return 0; } + if (head.equals("-http")) { + if (mode != null) { + printUsage("-http clashes with previously defined mode " + mode); + return -1; + } + mode = Mode.HTTP; + args = args.subList(1, args.size()); + continue; + } + if (head.equals("-ssh")) { + if (mode != null) { + printUsage("-ssh clashes with previously defined mode " + mode); + return -1; + } + mode = Mode.SSH; + args = args.subList(1, args.size()); + continue; + } + if (head.equals("-remoting")) { + if (mode != null) { + printUsage("-remoting clashes with previously defined mode " + mode); + return -1; + } + mode = Mode.REMOTING; + args = args.subList(1, args.size()); + continue; + } if(head.equals("-s") && args.size()>=2) { url = args.get(1); args = args.subList(2,args.size()); continue; } if (head.equals("-noCertificateCheck")) { - System.err.println("Skipping HTTPS certificate checks altogether. Note that this is not secure at all."); + LOGGER.info("Skipping HTTPS certificate checks altogether. Note that this is not secure at all."); SSLContext context = SSLContext.getInstance("TLS"); context.init(null, new TrustManager[]{new NoCheckTrustManager()}, new SecureRandom()); HttpsURLConnection.setDefaultSSLSocketFactory(context.getSocketFactory()); @@ -456,18 +502,35 @@ public class CLI implements AutoCloseable { sshAuthRequestedExplicitly = true; continue; } + if (head.equals("-strictHostKey")) { + strictHostKey = true; + args = args.subList(1, args.size()); + continue; + } + if (head.equals("-user") && args.size() >= 2) { + user = args.get(1); + args = args.subList(2, args.size()); + continue; + } + if (head.equals("-auth") && args.size() >= 2) { + auth = args.get(1); + args = args.subList(2, args.size()); + continue; + } if(head.equals("-p") && args.size()>=2) { httpProxy = args.get(1); args = args.subList(2,args.size()); continue; } - if(head.equals("-v")) { - args = args.subList(1,args.size()); - Logger l = Logger.getLogger(ROOT_LOGGER_NAME); - l.setLevel(FINEST); - for (Handler h : l.getHandlers()) { - h.setLevel(FINEST); + if (head.equals("-logger") && args.size() >= 2) { + Level level = parse(args.get(1)); + for (Handler h : Logger.getLogger("").getHandlers()) { + h.setLevel(level); + } + for (Logger logger : new Logger[] {LOGGER, PlainCLIProtocol.LOGGER, Logger.getLogger("org.apache.sshd")}) { // perhaps also Channel + logger.setLevel(level); } + args = args.subList(2, args.size()); continue; } break; @@ -478,16 +541,53 @@ public class CLI implements AutoCloseable { return -1; } + if (!url.endsWith("/")) { + url += '/'; + } + if(args.isEmpty()) args = Arrays.asList("help"); // default to help if (tryLoadPKey && !provider.hasKeys()) provider.readFromDefaultLocations(); + if (mode == null) { + mode = Mode.HTTP; + } + + LOGGER.log(FINE, "using connection mode {0}", mode); + + if (user != null && auth != null) { + LOGGER.warning("-user and -auth are mutually exclusive"); + } + + if (mode == Mode.SSH) { + if (user == null) { + // TODO SshCliAuthenticator already autodetects the user based on public key; why cannot AsynchronousCommand.getCurrentUser do the same? + LOGGER.warning("-user required when using -ssh"); + return -1; + } + return SSHCLI.sshConnection(url, user, args, provider, strictHostKey); + } + + if (strictHostKey) { + LOGGER.warning("-strictHostKey meaningful only with -ssh"); + } + + if (user != null) { + LOGGER.warning("Warning: -user ignored unless using -ssh"); + } + CLIConnectionFactory factory = new CLIConnectionFactory().url(url).httpsProxyTunnel(httpProxy); String userInfo = new URL(url).getUserInfo(); if (userInfo != null) { factory = factory.basicAuth(userInfo); + } else if (auth != null) { + factory = factory.basicAuth(auth.startsWith("@") ? FileUtils.readFileToString(new File(auth.substring(1))).trim() : auth); + } + + if (mode == Mode.HTTP) { + return plainHttpConnection(url, args, factory); } CLI cli = factory.connect(); @@ -498,22 +598,21 @@ public class CLI implements AutoCloseable { cli.authenticate(provider.getKeys()); } catch (IllegalStateException e) { if (sshAuthRequestedExplicitly) { - System.err.println("The server doesn't support public key authentication"); + LOGGER.warning("The server doesn't support public key authentication"); return -1; } } catch (UnsupportedOperationException e) { if (sshAuthRequestedExplicitly) { - System.err.println("The server doesn't support public key authentication"); + LOGGER.warning("The server doesn't support public key authentication"); return -1; } } catch (GeneralSecurityException e) { if (sshAuthRequestedExplicitly) { - System.err.println(e.getMessage()); - LOGGER.log(FINE,e.getMessage(),e); + LOGGER.log(WARNING, null, e); return -1; } - System.err.println("[WARN] Failed to authenticate with your SSH keys. Proceeding as anonymous"); - LOGGER.log(FINE,"Failed to authenticate with your SSH keys.",e); + LOGGER.warning("Failed to authenticate with your SSH keys. Proceeding as anonymous"); + LOGGER.log(FINE, null, e); } } @@ -526,6 +625,72 @@ public class CLI implements AutoCloseable { } } + 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?"); + } + } + @Override + protected 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(); + } + } + try (final ClientSideImpl connection = new ClientSideImpl(streams.getInputStream(), streams.getOutputStream())) { + for (String arg : args) { + connection.sendArg(arg); + } + connection.sendEncoding(Charset.defaultCharset().name()); + connection.sendLocale(Locale.getDefault().toString()); + connection.sendStart(); + connection.begin(); + final OutputStream stdin = connection.streamStdin(); + new Thread("input reader") { + @Override + public void run() { + try { + int c; + while ((c = System.in.read()) != -1) { // TODO use InputStream.available + stdin.write(c); + } + connection.sendEndStdin(); + } catch (IOException x) { + LOGGER.log(Level.WARNING, null, x); + } + } + }.start(); + synchronized (connection) { + while (!connection.complete) { + connection.wait(); + } + } + return connection.exit; + } + } + private static String computeVersion() { Properties props = new Properties(); try { @@ -570,7 +735,9 @@ public class CLI implements AutoCloseable { * * @return * identity of the server represented as a public key. + * @deprecated Specific to {@link Mode#REMOTING}. */ + @Deprecated public PublicKey authenticate(Iterable privateKeys) throws IOException, GeneralSecurityException { Pipe c2s = Pipe.createLocalToRemote(); Pipe s2c = Pipe.createRemoteToLocal(); @@ -596,6 +763,10 @@ public class CLI implements AutoCloseable { } } + /** + * @deprecated Specific to {@link Mode#REMOTING}. + */ + @Deprecated public PublicKey authenticate(KeyPair key) throws IOException, GeneralSecurityException { return authenticate(Collections.singleton(key)); } @@ -605,6 +776,5 @@ public class CLI implements AutoCloseable { System.err.println(Messages.CLI_Usage()); } - private static final Logger LOGGER = Logger.getLogger(CLI.class.getName()); - private static final String ROOT_LOGGER_NAME = CLI.class.getPackage().getName(); + static final Logger LOGGER = Logger.getLogger(CLI.class.getName()); } diff --git a/cli/src/main/java/hudson/cli/CLIConnectionFactory.java b/cli/src/main/java/hudson/cli/CLIConnectionFactory.java index a2e5681039effafbb22765a249a508fe8fdb22b2..894e4c0fdac60ff446e3a67ecf68e44192d93f97 100644 --- a/cli/src/main/java/hudson/cli/CLIConnectionFactory.java +++ b/cli/src/main/java/hudson/cli/CLIConnectionFactory.java @@ -32,6 +32,7 @@ public class CLIConnectionFactory { /** * This {@link ExecutorService} is used to execute closures received from the server. + * Used only in Remoting mode. */ public CLIConnectionFactory executorService(ExecutorService es) { this.exec = es; @@ -59,15 +60,24 @@ public class CLIConnectionFactory { /** * Convenience method to call {@link #authorization} with the HTTP basic authentication. + * Currently unused. */ public CLIConnectionFactory basicAuth(String username, String password) { return basicAuth(username+':'+password); } + /** + * Convenience method to call {@link #authorization} with the HTTP basic authentication. + * Cf. {@code BasicHeaderApiTokenAuthenticator}. + */ public CLIConnectionFactory basicAuth(String userInfo) { return authorization("Basic " + new String(Base64.encodeBase64((userInfo).getBytes()))); } - + + /** + * @deprecated Specific to Remoting-based protocol. + */ + @Deprecated public CLI connect() throws IOException, InterruptedException { return new CLI(this); } diff --git a/cli/src/main/java/hudson/cli/CliEntryPoint.java b/cli/src/main/java/hudson/cli/CliEntryPoint.java index 57bd42e553b77336c2a0db0085ae4ce0474c76b2..fe1f6c835097888be71632babbe31f1b6530fab5 100644 --- a/cli/src/main/java/hudson/cli/CliEntryPoint.java +++ b/cli/src/main/java/hudson/cli/CliEntryPoint.java @@ -34,7 +34,9 @@ import java.util.Locale; * Remotable interface for CLI entry point on the server side. * * @author Kohsuke Kawaguchi + * @deprecated Specific to Remoting-based protocol. */ +@Deprecated public interface CliEntryPoint { /** * Just like the static main method. diff --git a/cli/src/main/java/hudson/cli/CliPort.java b/cli/src/main/java/hudson/cli/CliPort.java index 8faab7de41bee0c88fa90cdaa0cbb8c9599f6a97..56165e766ba6186489d4e3017b0a7a35510358ab 100644 --- a/cli/src/main/java/hudson/cli/CliPort.java +++ b/cli/src/main/java/hudson/cli/CliPort.java @@ -8,9 +8,9 @@ import java.security.KeyFactory; import java.security.PublicKey; import java.security.spec.X509EncodedKeySpec; -/** - * @author Kohsuke Kawaguchi - */ + /** + * @deprecated Specific to Remoting mode. + */ public final class CliPort { /** * The TCP endpoint to talk to. diff --git a/cli/src/main/java/hudson/cli/Connection.java b/cli/src/main/java/hudson/cli/Connection.java index 1c1ada471fdbaaded6f2203ec130a7e69a671a6f..017051a64a646a76b4ecfe81966381e3874adbca 100644 --- a/cli/src/main/java/hudson/cli/Connection.java +++ b/cli/src/main/java/hudson/cli/Connection.java @@ -56,6 +56,9 @@ import java.security.interfaces.DSAPublicKey; import java.security.interfaces.RSAPublicKey; import java.security.spec.X509EncodedKeySpec; +/** + * Used by Jenkins core only in deprecated Remoting-based CLI. + */ public class Connection { public final InputStream in; public final OutputStream out; diff --git a/cli/src/main/java/hudson/cli/DiagnosedStreamCorruptionException.java b/cli/src/main/java/hudson/cli/DiagnosedStreamCorruptionException.java new file mode 100644 index 0000000000000000000000000000000000000000..4708b425dbb77a715910ac28c67ec84aca79db0f --- /dev/null +++ b/cli/src/main/java/hudson/cli/DiagnosedStreamCorruptionException.java @@ -0,0 +1,55 @@ +package hudson.cli; + +import java.io.PrintWriter; +import java.io.StreamCorruptedException; +import java.io.StringWriter; + +// TODO COPIED FROM hudson.remoting + +/** + * Signals a {@link StreamCorruptedException} with some additional diagnostic information. + * + * @author Kohsuke Kawaguchi + */ +class DiagnosedStreamCorruptionException extends StreamCorruptedException { + private final Exception diagnoseFailure; + private final byte[] readBack; + private final byte[] readAhead; + + DiagnosedStreamCorruptionException(Exception cause, Exception diagnoseFailure, byte[] readBack, byte[] readAhead) { + initCause(cause); + this.diagnoseFailure = diagnoseFailure; + this.readBack = readBack; + this.readAhead = readAhead; + } + + public Exception getDiagnoseFailure() { + return diagnoseFailure; + } + + public byte[] getReadBack() { + return readBack; + } + + public byte[] getReadAhead() { + return readAhead; + } + + @Override + public String toString() { + StringBuilder buf = new StringBuilder(); + buf.append(super.toString()).append("\n"); + buf.append("Read back: ").append(HexDump.toHex(readBack)).append('\n'); + buf.append("Read ahead: ").append(HexDump.toHex(readAhead)); + if (diagnoseFailure!=null) { + StringWriter w = new StringWriter(); + PrintWriter p = new PrintWriter(w); + diagnoseFailure.printStackTrace(p); + p.flush(); + + buf.append("\nDiagnosis problem:\n "); + buf.append(w.toString().trim().replace("\n","\n ")); + } + return buf.toString(); + } +} diff --git a/cli/src/main/java/hudson/cli/FlightRecorderInputStream.java b/cli/src/main/java/hudson/cli/FlightRecorderInputStream.java new file mode 100644 index 0000000000000000000000000000000000000000..ebdd18a192fec4d2e29e25c85dbeb0c2679e40d6 --- /dev/null +++ b/cli/src/main/java/hudson/cli/FlightRecorderInputStream.java @@ -0,0 +1,191 @@ +package hudson.cli; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.Arrays; + +// TODO COPIED FROM hudson.remoting + +/** + * Filter input stream that records the content as it's read, so that it can be reported + * in case of a catastrophic stream corruption problem. + * + * @author Kohsuke Kawaguchi + */ +class FlightRecorderInputStream extends InputStream { + + /** + * Size (in bytes) of the flight recorder ring buffer used for debugging remoting issues. + * @since 2.41 + */ + static final int BUFFER_SIZE = Integer.getInteger("hudson.remoting.FlightRecorderInputStream.BUFFER_SIZE", 1024 * 1024); + + private final InputStream source; + private ByteArrayRingBuffer recorder = new ByteArrayRingBuffer(BUFFER_SIZE); + + FlightRecorderInputStream(InputStream source) { + this.source = source; + } + + /** + * Rewinds the record buffer and forget everything that was recorded. + */ + public void clear() { + recorder = new ByteArrayRingBuffer(BUFFER_SIZE); + } + + /** + * Gets the recorded content. + */ + public byte[] getRecord() { + return recorder.toByteArray(); + } + + /** + * Creates a {@link DiagnosedStreamCorruptionException} based on the recorded content plus read ahead. + * The caller is responsible for throwing the exception. + */ + public DiagnosedStreamCorruptionException analyzeCrash(Exception problem, String diagnosisName) { + final ByteArrayOutputStream readAhead = new ByteArrayOutputStream(); + final IOException[] error = new IOException[1]; + + Thread diagnosisThread = new Thread(diagnosisName+" stream corruption diagnosis thread") { + public void run() { + int b; + try { + // not all InputStream will look for the thread interrupt flag, so check that explicitly to be defensive + while (!Thread.interrupted() && (b=source.read())!=-1) { + readAhead.write(b); + } + } catch (IOException e) { + error[0] = e; + } + } + }; + + // wait up to 1 sec to grab as much data as possible + diagnosisThread.start(); + try { + diagnosisThread.join(1000); + } catch (InterruptedException ignored) { + // we are only waiting for a fixed amount of time, so we'll pretend like we were in a busy loop + Thread.currentThread().interrupt(); + // fall through + } + + IOException diagnosisProblem = error[0]; // capture the error, if any, before we kill the thread + if (diagnosisThread.isAlive()) + diagnosisThread.interrupt(); // if it's not dead, kill + + return new DiagnosedStreamCorruptionException(problem,diagnosisProblem,getRecord(),readAhead.toByteArray()); + + } + + @Override + public int read() throws IOException { + int i = source.read(); + if (i>=0) + recorder.write(i); + return i; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + len = source.read(b, off, len); + if (len>0) + recorder.write(b,off,len); + return len; + } + + /** + * To record the bytes we've skipped, convert the call to read. + */ + @Override + public long skip(long n) throws IOException { + byte[] buf = new byte[(int)Math.min(n,64*1024)]; + return read(buf,0,buf.length); + } + + @Override + public int available() throws IOException { + return source.available(); + } + + @Override + public void close() throws IOException { + source.close(); + } + + @Override + public boolean markSupported() { + return false; + } + + // http://stackoverflow.com/a/3651696/12916 + private static class ByteArrayRingBuffer extends OutputStream { + + byte[] data; + + int capacity, pos = 0; + + boolean filled = false; + + public ByteArrayRingBuffer(int capacity) { + data = new byte[capacity]; + this.capacity = capacity; + } + + @Override + public synchronized void write(int b) { + if (pos == capacity) { + filled = true; + pos = 0; + } + data[pos++] = (byte) b; + } + + public synchronized byte[] toByteArray() { + if (!filled) { + return Arrays.copyOf(data, pos); + } + byte[] ret = new byte[capacity]; + System.arraycopy(data, pos, ret, 0, capacity - pos); + System.arraycopy(data, 0, ret, capacity - pos, pos); + return ret; + } + + /** @author @roadrunner2 */ + @Override public synchronized void write(byte[] buf, int off, int len) { + // no point in trying to copy more than capacity; this also simplifies logic below + if (len > capacity) { + off += (len - capacity); + len = capacity; + } + + // copy to buffer, but no farther than the end + int num = Math.min(len, capacity - pos); + if (num > 0) { + System.arraycopy(buf, off, data, pos, num); + off += num; + len -= num; + pos += num; + } + + // wrap around if necessary + if (pos == capacity) { + filled = true; + pos = 0; + } + + // copy anything still left + if (len > 0) { + System.arraycopy(buf, off, data, pos, len); + pos += len; + } + } + + } + +} diff --git a/cli/src/main/java/hudson/cli/FullDuplexHttpStream.java b/cli/src/main/java/hudson/cli/FullDuplexHttpStream.java index 3c7911e1723d0191340dcd457af46a23fbb5a65e..c01f3261ceba3cefcaa1cab4a759daefc1a78e90 100644 --- a/cli/src/main/java/hudson/cli/FullDuplexHttpStream.java +++ b/cli/src/main/java/hudson/cli/FullDuplexHttpStream.java @@ -20,7 +20,7 @@ import org.apache.commons.codec.binary.Base64; * @author Kohsuke Kawaguchi */ public class FullDuplexHttpStream { - private final URL target; + private final URL base; /** * Authorization header value needed to get through the HTTP layer. */ @@ -49,15 +49,36 @@ public class FullDuplexHttpStream { } /** + * @param target something like {@code http://jenkins/cli?remoting=true} + * which we then need to split into {@code http://jenkins/} + {@code cli?remoting=true} + * in order to construct a crumb issuer request + * @deprecated use {@link #FullDuplexHttpStream(URL, String, String)} instead + */ + @Deprecated + public FullDuplexHttpStream(URL target, String authorization) throws IOException { + this(new URL(target.toString().replaceFirst("/cli.*$", "/")), target.toString().replaceFirst("^.+/(cli.*)$", "$1"), authorization); + } + + /** + * @param base the base URL of Jenkins * @param target * The endpoint that we are making requests to. * @param authorization * The value of the authorization header, if non-null. */ - public FullDuplexHttpStream(URL target, String authorization) throws IOException { - this.target = target; + public FullDuplexHttpStream(URL base, String relativeTarget, String authorization) throws IOException { + if (!base.toString().endsWith("/")) { + throw new IllegalArgumentException(base.toString()); + } + if (relativeTarget.startsWith("/")) { + throw new IllegalArgumentException(relativeTarget); + } + + this.base = base; this.authorization = authorization; + URL target = new URL(base, relativeTarget); + CrumbData crumbData = new CrumbData(); UUID uuid = UUID.randomUUID(); // so that the server can correlate those two connections @@ -77,8 +98,9 @@ public class FullDuplexHttpStream { con.getOutputStream().close(); input = con.getInputStream(); // make sure we hit the right URL - if(con.getHeaderField("Hudson-Duplex")==null) - throw new IOException(target+" doesn't look like Jenkins"); + if (con.getHeaderField("Hudson-Duplex") == null) { + throw new IOException(target + " does not look like Jenkins, or is not serving the HTTP Duplex transport"); + } // client->server uses chunked encoded POST for unlimited capacity. con = (HttpURLConnection) target.openConnection(); @@ -128,8 +150,7 @@ public class FullDuplexHttpStream { } private String createCrumbUrlBase() { - String url = target.toExternalForm(); - return new StringBuilder(url.substring(0, url.lastIndexOf("/cli"))).append("/crumbIssuer/api/xml/").toString(); + return base + "crumbIssuer/api/xml/"; } private String readData(String dest) throws IOException { diff --git a/cli/src/main/java/hudson/cli/HexDump.java b/cli/src/main/java/hudson/cli/HexDump.java new file mode 100644 index 0000000000000000000000000000000000000000..ad37158bc16d0e7939a34831bd7628120eb61207 --- /dev/null +++ b/cli/src/main/java/hudson/cli/HexDump.java @@ -0,0 +1,47 @@ +package hudson.cli; + +// TODO COPIED FROM hudson.remoting + +/** + * @author Kohsuke Kawaguchi + */ +class HexDump { + private static final String CODE = "0123456789abcdef"; + + public static String toHex(byte[] buf) { + return toHex(buf,0,buf.length); + } + public static String toHex(byte[] buf, int start, int len) { + StringBuilder r = new StringBuilder(len*2); + boolean inText = false; + for (int i=0; i= 0x20 && b <= 0x7e) { + if (!inText) { + inText = true; + r.append('\''); + } + r.append((char) b); + } else { + if (inText) { + r.append("' "); + inText = false; + } + r.append("0x"); + r.append(CODE.charAt((b>>4)&15)); + r.append(CODE.charAt(b&15)); + if (i < len - 1) { + if (b == 10) { + r.append('\n'); + } else { + r.append(' '); + } + } + } + } + if (inText) { + r.append('\''); + } + return r.toString(); + } +} diff --git a/cli/src/main/java/hudson/cli/PlainCLIProtocol.java b/cli/src/main/java/hudson/cli/PlainCLIProtocol.java new file mode 100644 index 0000000000000000000000000000000000000000..17d232c87b570ab0d80069ed926a54613429451f --- /dev/null +++ b/cli/src/main/java/hudson/cli/PlainCLIProtocol.java @@ -0,0 +1,354 @@ +/* + * The MIT License + * + * Copyright 2017 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 hudson.cli; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.channels.ClosedChannelException; +import java.nio.channels.ReadPendingException; +import java.util.concurrent.TimeoutException; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.apache.commons.io.IOUtils; +import org.apache.commons.io.input.CountingInputStream; + +/** + * CLI protocol working over a plain socket-like connection, without SSH or Remoting. + * Each side consists of frames starting with an {@code int} length, + * then a {@code byte} opcode, then any opcode-specific data. + * The length does not count the length field itself nor the opcode, so it is nonnegative. + */ +class PlainCLIProtocol { + + static final Logger LOGGER = Logger.getLogger(PlainCLIProtocol.class.getName()); + + /** One-byte operation to send to the other side. */ + private enum Op { + /** UTF-8 command name or argument. */ + ARG(true), + /** UTF-8 locale identifier. */ + LOCALE(true), + /** UTF-8 client encoding. */ + ENCODING(true), + /** Start running command. */ + START(true), + /** Exit code, as int. */ + EXIT(false), + /** Chunk of stdin, as int length followed by bytes. */ + STDIN(true), + /** EOF on stdin. */ + END_STDIN(true), + /** Chunk of stdout. */ + STDOUT(false), + /** Chunk of stderr. */ + STDERR(false); + /** True if sent from the client to the server; false if sent from the server to the client. */ + final boolean clientSide; + Op(boolean clientSide) { + this.clientSide = clientSide; + } + } + + static abstract class EitherSide implements Closeable { + + private final CountingInputStream cis; + private final FlightRecorderInputStream flightRecorder; + final DataInputStream dis; + final DataOutputStream dos; + + protected EitherSide(InputStream is, OutputStream os) { + cis = new CountingInputStream(is); + flightRecorder = new FlightRecorderInputStream(cis); + dis = new DataInputStream(flightRecorder); + dos = new DataOutputStream(os); + } + + final void begin() { + new Reader().start(); + } + + private class Reader extends Thread { + + Reader() { + super("PlainCLIProtocol"); // TODO set distinctive Thread.name + } + + @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 + } catch (IOException x) { + if (x.getCause() instanceof TimeoutException) { // TODO on Tomcat this seems to be SocketTimeoutException + LOGGER.log(Level.FINE, "ignoring idle timeout, perhaps from Jetty", x); + continue; + } else { + throw x; + } + } + 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); + } + } + } 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(); + } + } + + } + + protected abstract void handleClose(); + + protected abstract boolean handle(Op op, int framelen) throws IOException; + + private void writeOp(Op op) throws IOException { + dos.writeByte((byte) op.ordinal()); + } + + protected final synchronized void send(Op op) throws IOException { + dos.writeInt(0); + writeOp(op); + dos.flush(); + } + + 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, byte[] chunk, int off, int len) throws IOException { + dos.writeInt(len); + writeOp(op); + dos.write(chunk, off, len); + dos.flush(); + } + + protected final void send(Op op, byte[] chunk) throws IOException { + send(op, chunk, 0, chunk.length); + } + + protected final void send(Op op, String text) throws IOException { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + new DataOutputStream(buf).writeUTF(text); + 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 + public void write(int b) throws IOException { + send(op, new byte[] {(byte) b}); + } + @Override + public void write(byte[] b, int off, int len) throws IOException { + send(op, b, off, len); + } + @Override + public void write(byte[] b) throws IOException { + send(op, b); + } + }; + } + + @Override + public synchronized void close() throws IOException { + dos.close(); + } + + } + + static abstract class ServerSide extends EitherSide { + + ServerSide(InputStream is, OutputStream os) { + super(is, os); + } + + @Override + protected final boolean handle(Op op, int framelen) throws IOException { + assert Thread.currentThread() instanceof EitherSide.Reader; + assert op.clientSide; + switch (op) { + case ARG: + onArg(dis.readUTF()); + return true; + case LOCALE: + onLocale(dis.readUTF()); + return true; + case ENCODING: + onEncoding(dis.readUTF()); + return true; + case START: + onStart(); + return true; + case STDIN: + onStdin(readChunk(framelen)); + return true; + case END_STDIN: + onEndStdin(); + return true; + default: + return false; + } + } + + protected abstract void onArg(String text); + + protected abstract void onLocale(String text); + + protected abstract void onEncoding(String text); + + protected abstract void onStart(); + + protected abstract void onStdin(byte[] chunk) throws IOException; + + protected abstract void onEndStdin() throws IOException; + + public final void sendExit(int code) throws IOException { + send(Op.EXIT, code); + } + + public final OutputStream streamStdout() { + return stream(Op.STDOUT); + } + + public final OutputStream streamStderr() { + return stream(Op.STDERR); + } + + } + + static abstract class ClientSide extends EitherSide { + + ClientSide(InputStream is, OutputStream os) { + super(is, os); + } + + @Override + protected boolean handle(Op op, int framelen) throws IOException { + assert Thread.currentThread() instanceof EitherSide.Reader; + assert !op.clientSide; + switch (op) { + case EXIT: + onExit(dis.readInt()); + return true; + case STDOUT: + onStdout(readChunk(framelen)); + return true; + case STDERR: + onStderr(readChunk(framelen)); + return true; + default: + return false; + } + } + + protected abstract void onExit(int code); + + protected abstract void onStdout(byte[] chunk) throws IOException; + + protected abstract void onStderr(byte[] chunk) throws IOException; + + public final void sendArg(String text) throws IOException { + send(Op.ARG, text); + } + + public final void sendLocale(String text) throws IOException { + send(Op.LOCALE, text); + } + + public final void sendEncoding(String text) throws IOException { + send(Op.ENCODING, text); + } + + public final void sendStart() throws IOException { + send(Op.START); + } + + public final OutputStream streamStdin() { + return stream(Op.STDIN); + } + + public final void sendEndStdin() throws IOException { + send(Op.END_STDIN); + } + + } + + private PlainCLIProtocol() {} + +} diff --git a/cli/src/main/java/hudson/cli/SSHCLI.java b/cli/src/main/java/hudson/cli/SSHCLI.java new file mode 100644 index 0000000000000000000000000000000000000000..8a2ecc4f388562f740e4c6a4310d9dd0ee6c4c0d --- /dev/null +++ b/cli/src/main/java/hudson/cli/SSHCLI.java @@ -0,0 +1,132 @@ +/* + * The MIT License + * + * Copyright 2017 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 hudson.cli; + +import hudson.util.QuotedStringTokenizer; +import java.io.IOException; +import java.net.SocketAddress; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.net.URLConnection; +import java.security.KeyPair; +import java.security.PublicKey; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.logging.Level; +import static java.util.logging.Level.FINE; +import java.util.logging.Logger; +import org.apache.sshd.client.SshClient; +import org.apache.sshd.client.channel.ClientChannel; +import org.apache.sshd.client.channel.ClientChannelEvent; +import org.apache.sshd.client.future.ConnectFuture; +import org.apache.sshd.client.keyverifier.DefaultKnownHostsServerKeyVerifier; +import org.apache.sshd.client.keyverifier.KnownHostsServerKeyVerifier; +import org.apache.sshd.client.keyverifier.ServerKeyVerifier; +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.future.WaitableFuture; +import org.apache.sshd.common.util.SecurityUtils; +import org.apache.sshd.common.util.io.NoCloseInputStream; +import org.apache.sshd.common.util.io.NoCloseOutputStream; + +/** + * Implements SSH connection mode of {@link CLI}. + * In a separate class to avoid any class loading of {@code sshd-core} when not using {@code -ssh} mode. + * That allows the {@code test} module to pick up a version of {@code sshd-core} from the {@code sshd} module via {@code jenkins-war} + * that may not match the version being used from the {@code cli} module and may not be compatible. + */ +class SSHCLI { + + static int sshConnection(String jenkinsUrl, String user, List args, PrivateKeyProvider provider, final boolean strictHostKey) throws IOException { + Logger.getLogger(SecurityUtils.class.getName()).setLevel(Level.WARNING); // suppress: BouncyCastle not registered, using the default JCE provider + URL url = new URL(jenkinsUrl + "login"); + URLConnection conn = url.openConnection(); + String endpointDescription = conn.getHeaderField("X-SSH-Endpoint"); + + if (endpointDescription == null) { + CLI.LOGGER.warning("No header 'X-SSH-Endpoint' returned by Jenkins"); + return -1; + } + + CLI.LOGGER.log(FINE, "Connecting via SSH to: {0}", endpointDescription); + + int sshPort = Integer.parseInt(endpointDescription.split(":")[1]); + String sshHost = endpointDescription.split(":")[0]; + + StringBuilder command = new StringBuilder(); + + for (String arg : args) { + command.append(QuotedStringTokenizer.quote(arg)); + command.append(' '); + } + + try(SshClient client = SshClient.setUpDefaultClient()) { + + KnownHostsServerKeyVerifier verifier = new DefaultKnownHostsServerKeyVerifier(new ServerKeyVerifier() { + @Override + public boolean verifyServerKey(ClientSession clientSession, SocketAddress remoteAddress, PublicKey serverKey) { + CLI.LOGGER.log(Level.WARNING, "Unknown host key for {0}", remoteAddress.toString()); + return !strictHostKey; + } + }, true); + + client.setServerKeyVerifier(verifier); + client.start(); + + ConnectFuture cf = client.connect(user, sshHost, sshPort); + cf.await(); + try (ClientSession session = cf.getSession()) { + for (KeyPair pair : provider.getKeys()) { + CLI.LOGGER.log(FINE, "Offering {0} private key", pair.getPrivate().getAlgorithm()); + session.addPublicKeyIdentity(pair); + } + session.auth().verify(10000L); + + try (ClientChannel channel = session.createExecChannel(command.toString())) { + channel.setIn(new NoCloseInputStream(System.in)); + channel.setOut(new NoCloseOutputStream(System.out)); + channel.setErr(new NoCloseOutputStream(System.err)); + WaitableFuture wf = channel.open(); + wf.await(); + + Set waitMask = channel.waitFor(Collections.singletonList(ClientChannelEvent.CLOSED), 0L); + + if(waitMask.contains(ClientChannelEvent.TIMEOUT)) { + throw new SocketTimeoutException("Failed to retrieve command result in time: " + command); + } + + Integer exitStatus = channel.getExitStatus(); + return exitStatus; + + } + } finally { + client.stop(); + } + } + } + + private SSHCLI() {} + +} diff --git a/cli/src/main/java/hudson/cli/SequenceOutputStream.java b/cli/src/main/java/hudson/cli/SequenceOutputStream.java deleted file mode 100644 index fb6c26523b47317af89a975c7bd67930095ccabc..0000000000000000000000000000000000000000 --- a/cli/src/main/java/hudson/cli/SequenceOutputStream.java +++ /dev/null @@ -1,74 +0,0 @@ -package hudson.cli; - -import java.io.OutputStream; -import java.io.IOException; -import java.io.SequenceInputStream; - -/** - * {@link OutputStream} version of {@link SequenceInputStream}. - * - * Provides a single {@link OutputStream} view over multiple {@link OutputStream}s (each of the fixed length.) - * - * @author Kohsuke Kawaguchi - */ -abstract class SequenceOutputStream extends OutputStream { - protected static class Block { - final OutputStream out; - long capacity; - - public Block(OutputStream out, long capacity) { - this.out = out; - this.capacity = capacity; - } - } - - /** - * Current block being written. - */ - private Block block; - - protected SequenceOutputStream(Block block) { - this.block = block; - } - - @Override - public void write(byte[] b, int off, int len) throws IOException { - while(len>0) { - int sz = (int)Math.min(len, block.capacity); - block.out.write(b,off,sz); - block.capacity -=sz; - len-=sz; - off+=sz; - swapIfNeeded(); - } - } - - public void write(int b) throws IOException { - block.out.write(b); - block.capacity--; - swapIfNeeded(); - } - - private void swapIfNeeded() throws IOException { - if(block.capacity >0) return; - block.out.close(); - block=next(block); - } - - @Override - public void flush() throws IOException { - block.out.flush(); - } - - @Override - public void close() throws IOException { - block.out.close(); - block=null; - } - - /** - * Fetches the next {@link OutputStream} to write to, - * along with their capacity. - */ - protected abstract Block next(Block current) throws IOException; -} diff --git a/core/src/main/java/hudson/util/QuotedStringTokenizer.java b/cli/src/main/java/hudson/util/QuotedStringTokenizer.java similarity index 100% rename from core/src/main/java/hudson/util/QuotedStringTokenizer.java rename to cli/src/main/java/hudson/util/QuotedStringTokenizer.java diff --git a/cli/src/main/resources/hudson/cli/client/Messages.properties b/cli/src/main/resources/hudson/cli/client/Messages.properties index 1c091fbfb0d20ce33432ee40dd0f356c40f236c1..921fe67a211da560ac75fc355179a117b3c00dc2 100644 --- a/cli/src/main/resources/hudson/cli/client/Messages.properties +++ b/cli/src/main/resources/hudson/cli/client/Messages.properties @@ -2,11 +2,18 @@ CLI.Usage=Jenkins CLI\n\ Usage: java -jar jenkins-cli.jar [-s URL] command [opts...] args...\n\ Options:\n\ -s URL : the server URL (defaults to the JENKINS_URL env var)\n\ - -i KEY : SSH private key file used for authentication\n\ + -http : use a plain CLI protocol over HTTP(S) (the default; mutually exclusive with -ssh and -remoting)\n\ + -ssh : use SSH protocol (requires -user; SSH port must be open on server, and user must have registered a public key)\n\ + -remoting : use deprecated Remoting channel protocol (if enabled on server; for compatibility with legacy commands or command modes only)\n\ + -i KEY : SSH private key file used for authentication (for use with -ssh or -remoting)\n\ -p HOST:PORT : HTTP proxy host and port for HTTPS proxy tunneling. See https://jenkins.io/redirect/cli-https-proxy-tunnel\n\ -noCertificateCheck : bypass HTTPS certificate check entirely. Use with caution\n\ -noKeyAuth : don't try to load the SSH authentication private key. Conflicts with -i\n\ - -v : verbose output. Display logs on the console by setting j.u.l log level to FINEST\n\ + -user : specify user (for use with -ssh)\n\ + -strictHostKey : request strict host key checking (for use with -ssh)\n\ + -logger FINE : enable detailed logging from the client\n\ + -auth [ USER:SECRET | @FILE ] : specify username and either password or API token (or load from them both from a file);\n\ + for use with -http, or -remoting but only when the JNLP agent port is disabled\n\ \n\ The available commands depend on the server. Run the 'help' command to\n\ see the list. diff --git a/cli/src/test/java/hudson/cli/PlainCLIProtocolTest.java b/cli/src/test/java/hudson/cli/PlainCLIProtocolTest.java new file mode 100644 index 0000000000000000000000000000000000000000..4663fbafd5e0aa1a05852562ef8c8560f3d0cac3 --- /dev/null +++ b/cli/src/test/java/hudson/cli/PlainCLIProtocolTest.java @@ -0,0 +1,132 @@ +/* + * The MIT License + * + * Copyright 2017 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 hudson.cli; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import static org.junit.Assert.*; +import org.junit.Test; + +public class PlainCLIProtocolTest { + + @Test + public void ignoreUnknownOperations() throws Exception { + final PipedOutputStream upload = new PipedOutputStream(); + final PipedOutputStream download = new PipedOutputStream(); + class Client extends PlainCLIProtocol.ClientSide { + int code = -1; + final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + Client() throws IOException { + super(new PipedInputStream(download), upload); + } + @Override + protected synchronized void onExit(int code) { + this.code = code; + notifyAll(); + } + @Override + protected void onStdout(byte[] chunk) throws IOException { + stdout.write(chunk); + } + @Override + protected void onStderr(byte[] chunk) throws IOException {} + @Override + protected void handleClose() {} + void send() throws IOException { + sendArg("command"); + sendStart(); + streamStdin().write("hello".getBytes()); + } + void newop() throws IOException { + dos.writeInt(0); + dos.writeByte(99); + dos.flush(); + } + } + class Server extends PlainCLIProtocol.ServerSide { + String arg; + boolean started; + final ByteArrayOutputStream stdin = new ByteArrayOutputStream(); + Server() throws IOException { + super(new PipedInputStream(upload), download); + } + @Override + protected void onArg(String text) { + arg = text; + } + @Override + protected void onLocale(String text) {} + @Override + protected void onEncoding(String text) {} + @Override + protected synchronized void onStart() { + started = true; + notifyAll(); + } + @Override + protected void onStdin(byte[] chunk) throws IOException { + stdin.write(chunk); + } + @Override + protected void onEndStdin() throws IOException {} + @Override + protected void handleClose() {} + void send() throws IOException { + streamStdout().write("goodbye".getBytes()); + sendExit(2); + } + void newop() throws IOException { + dos.writeInt(0); + dos.writeByte(99); + dos.flush(); + } + } + Client client = new Client(); + Server server = new Server(); + client.begin(); + server.begin(); + client.send(); + client.newop(); + synchronized (server) { + while (!server.started) { + server.wait(); + } + } + server.newop(); + server.send(); + synchronized (client) { + while (client.code == -1) { + client.wait(); + } + } + assertEquals("hello", server.stdin.toString()); + assertEquals("command", server.arg); + assertEquals("goodbye", client.stdout.toString()); + assertEquals(2, client.code); + } + +} diff --git a/cli/src/test/java/hudson/cli/PrivateKeyProviderTest.java b/cli/src/test/java/hudson/cli/PrivateKeyProviderTest.java index 376b1fa815f3f62988f741c93a63f2f2d090563a..86970265089b4bf41cb7bc4aae7d6e320447d21a 100644 --- a/cli/src/test/java/hudson/cli/PrivateKeyProviderTest.java +++ b/cli/src/test/java/hudson/cli/PrivateKeyProviderTest.java @@ -57,7 +57,7 @@ public class PrivateKeyProviderTest { final File dsaKey = keyFile(".ssh/id_dsa"); final File rsaKey = keyFile(".ssh/id_rsa"); - run("-i", dsaKey.getAbsolutePath(), "-i", rsaKey.getAbsolutePath(), "-s", "http://example.com"); + run("-remoting", "-i", dsaKey.getAbsolutePath(), "-i", rsaKey.getAbsolutePath(), "-s", "http://example.com"); verify(cli).authenticate(withKeyPairs( keyPair(dsaKey), @@ -73,7 +73,7 @@ public class PrivateKeyProviderTest { final File dsaKey = keyFile(".ssh/id_dsa"); fakeHome(); - run("-s", "http://example.com"); + run("-remoting", "-s", "http://example.com"); verify(cli).authenticate(withKeyPairs( keyPair(rsaKey), diff --git a/core/pom.xml b/core/pom.xml index f8d7e4eb465cde17dd55fe9283a8fd8a413dc77b..c9d2cee765292af3d31d58dced46318b21094046 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -772,7 +772,6 @@ THE SOFTWARE. 0.5C true -noverify - false diff --git a/core/src/main/java/hudson/cli/CLIAction.java b/core/src/main/java/hudson/cli/CLIAction.java index 0053c278bc3ab5333f81f55470c3f9592244e774..2c37db46cc0ed3e52184fe7d9ef9cb264bbca21b 100644 --- a/core/src/main/java/hudson/cli/CLIAction.java +++ b/core/src/main/java/hudson/cli/CLIAction.java @@ -37,7 +37,6 @@ import jenkins.model.Jenkins; import org.jenkinsci.Symbol; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; -import org.kohsuke.stapler.HttpResponses.HttpResponseException; import org.kohsuke.stapler.Stapler; import org.kohsuke.stapler.StaplerProxy; import org.kohsuke.stapler.StaplerRequest; @@ -46,6 +45,21 @@ import org.kohsuke.stapler.StaplerResponse; import hudson.Extension; import hudson.model.FullDuplexHttpChannel; import hudson.remoting.Channel; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.io.PrintStream; +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 org.kohsuke.stapler.HttpResponses; /** * Shows usage of CLI and commands. @@ -56,7 +70,9 @@ import hudson.remoting.Channel; @Restricted(NoExternalUse.class) public class CLIAction implements UnprotectedRootAction, StaplerProxy { - private transient final Map duplexChannels = new HashMap(); + private static final Logger LOGGER = Logger.getLogger(CLIAction.class.getName()); + + private transient final Map duplexServices = new HashMap<>(); public String getIconFileName() { return null; @@ -67,7 +83,7 @@ public class CLIAction implements UnprotectedRootAction, StaplerProxy { } public String getUrlName() { - return jenkins.CLI.DISABLED ? null : "cli"; + return "cli"; } public void doCommand(StaplerRequest req, StaplerResponse rsp) throws ServletException, IOException { @@ -91,46 +107,159 @@ public class CLIAction implements UnprotectedRootAction, StaplerProxy { StaplerRequest req = Stapler.getCurrentRequest(); if (req.getRestOfPath().length()==0 && "POST".equals(req.getMethod())) { // CLI connection request - throw new CliEndpointResponse(); + if ("false".equals(req.getParameter("remoting"))) { + throw new PlainCliEndpointResponse(); + } else if (jenkins.CLI.get().isEnabled()) { + throw new RemotingCliEndpointResponse(); + } else { + throw HttpResponses.forbidden(); + } } else { return this; } } /** - * Serves CLI-over-HTTP response. + * Serves {@link PlainCLIProtocol} response. */ - private class CliEndpointResponse extends HttpResponseException { - @Override - public void generateResponse(StaplerRequest req, StaplerResponse rsp, Object node) throws IOException, ServletException { - try { - // do not require any permission to establish a CLI connection - // the actual authentication for the connecting Channel is done by CLICommand + private class PlainCliEndpointResponse extends FullDuplexHttpService.Response { - UUID uuid = UUID.fromString(req.getHeader("Session")); - rsp.setHeader("Hudson-Duplex",""); // set the header so that the client would know + PlainCliEndpointResponse() { + super(duplexServices); + } - FullDuplexHttpChannel server; - if(req.getHeader("Side").equals("download")) { - duplexChannels.put(uuid,server=new FullDuplexHttpChannel(uuid, !Jenkins.getActiveInstance().hasPermission(Jenkins.ADMINISTER)) { + @Override + protected FullDuplexHttpService createService(StaplerRequest req, UUID uuid) throws IOException { + 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 main(Channel channel) throws IOException, InterruptedException { - // capture the identity given by the transport, since this can be useful for SecurityRealm.createCliAuthenticator() - channel.setProperty(CLICommand.TRANSPORT_AUTHENTICATION, Jenkins.getAuthentication()); - channel.setProperty(CliEntryPoint.class.getName(),new CliManagerImpl(channel)); + 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 { - server.download(req,rsp); - } finally { - duplexChannels.remove(uuid); } - } else { - duplexChannels.get(uuid).upload(req,rsp); } - } catch (InterruptedException e) { - throw new IOException(e); - } + }; + } + } + + /** + * Serves Remoting-over-HTTP response. + */ + private class RemotingCliEndpointResponse extends FullDuplexHttpService.Response { + + RemotingCliEndpointResponse() { + super(duplexServices); + } + + @Override + protected FullDuplexHttpService createService(StaplerRequest req, UUID uuid) throws IOException { + // do not require any permission to establish a CLI connection + // the actual authentication for the connecting Channel is done by CLICommand + + return new FullDuplexHttpChannel(uuid, !Jenkins.getInstance().hasPermission(Jenkins.ADMINISTER)) { + @SuppressWarnings("deprecation") + @Override + protected void main(Channel channel) throws IOException, InterruptedException { + // capture the identity given by the transport, since this can be useful for SecurityRealm.createCliAuthenticator() + channel.setProperty(CLICommand.TRANSPORT_AUTHENTICATION, Jenkins.getAuthentication()); + channel.setProperty(CliEntryPoint.class.getName(), new CliManagerImpl(channel)); + } + }; } } + } diff --git a/core/src/main/java/hudson/cli/CLICommand.java b/core/src/main/java/hudson/cli/CLICommand.java index bc5d2ea4c5d84de24b88239f4e4e9759569980e4..d507662037b575cc82fab21ed0fcb21c9e764850 100644 --- a/core/src/main/java/hudson/cli/CLICommand.java +++ b/core/src/main/java/hudson/cli/CLICommand.java @@ -71,6 +71,8 @@ import java.util.Locale; import java.util.UUID; import java.util.logging.Level; import java.util.logging.Logger; +import javax.annotation.CheckForNull; +import javax.annotation.Nonnull; /** * Base class for Hudson CLI. @@ -151,7 +153,9 @@ public abstract class CLICommand implements ExtensionPoint, Cloneable { *

* See {@link #checkChannel()} to get a channel and throw an user-friendly * exception + * @deprecated Specific to Remoting-based protocol. */ + @Deprecated public transient Channel channel; /** @@ -159,6 +163,11 @@ public abstract class CLICommand implements ExtensionPoint, Cloneable { */ public transient Locale locale; + /** + * The encoding of the client, if defined. + */ + private transient @CheckForNull Charset encoding; + /** * Set by the caller of the CLI system if the transport already provides * authentication. Due to the compatibility issue, we still allow the user @@ -316,17 +325,23 @@ public abstract class CLICommand implements ExtensionPoint, Cloneable { protected CmdLineParser getCmdLineParser() { return new CmdLineParser(this); } - + + /** + * @deprecated Specific to Remoting-based protocol. + */ + @Deprecated public Channel checkChannel() throws AbortException { if (channel==null) - throw new AbortException("This command can only run with Jenkins CLI. See https://jenkins.io/redirect/cli-command-requires-channel"); + throw new AbortException("This command is requesting the deprecated -remoting mode. See https://jenkins.io/redirect/cli-command-requires-channel"); return channel; } /** * Loads the persisted authentication information from {@link ClientAuthenticationCache} * if the current transport provides {@link Channel}. + * @deprecated Assumes Remoting, and vulnerable to JENKINS-12543. */ + @Deprecated protected Authentication loadStoredAuthentication() throws InterruptedException { try { if (channel!=null) @@ -353,7 +368,9 @@ public abstract class CLICommand implements ExtensionPoint, Cloneable { * Always non-null. * If the underlying transport had already performed authentication, this object is something other than * {@link jenkins.model.Jenkins#ANONYMOUS}. + * @deprecated Unused. */ + @Deprecated protected boolean shouldPerformAuthentication(Authentication auth) { return auth== Jenkins.ANONYMOUS; } @@ -463,7 +480,9 @@ public abstract class CLICommand implements ExtensionPoint, Cloneable { /** * Convenience method for subtypes to obtain the system property of the client. + * @deprecated Specific to Remoting-based protocol. */ + @Deprecated protected String getClientSystemProperty(String name) throws IOException, InterruptedException { return checkChannel().call(new GetSystemProperty(name)); } @@ -482,7 +501,18 @@ public abstract class CLICommand implements ExtensionPoint, Cloneable { private static final long serialVersionUID = 1L; } - protected Charset getClientCharset() throws IOException, InterruptedException { + /** + * Define the encoding for the command. + * @since 2.54 + */ + public void setClientCharset(@Nonnull Charset encoding) { + this.encoding = encoding; + } + + protected @Nonnull Charset getClientCharset() throws IOException, InterruptedException { + if (encoding != null) { + return encoding; + } if (channel==null) // for SSH, assume the platform default encoding // this is in-line with the standard SSH behavior @@ -507,7 +537,9 @@ public abstract class CLICommand implements ExtensionPoint, Cloneable { /** * Convenience method for subtypes to obtain environment variables of the client. + * @deprecated Specific to Remoting-based protocol. */ + @Deprecated protected String getClientEnvironmentVariable(String name) throws IOException, InterruptedException { return checkChannel().call(new GetEnvironmentVariable(name)); } diff --git a/core/src/main/java/hudson/cli/CliManagerImpl.java b/core/src/main/java/hudson/cli/CliManagerImpl.java index 577cf3ce132fe373b6f95e80c95ff2415e75e366..750a38d210af7cb8f267086346715b3981129b50 100644 --- a/core/src/main/java/hudson/cli/CliManagerImpl.java +++ b/core/src/main/java/hudson/cli/CliManagerImpl.java @@ -44,7 +44,9 @@ import java.util.logging.Logger; * {@link CliEntryPoint} implementation exposed to the remote CLI. * * @author Kohsuke Kawaguchi + * @deprecated Specific to Remoting-based protocol. */ +@Deprecated public class CliManagerImpl implements CliEntryPoint, Serializable { private transient final Channel channel; diff --git a/core/src/main/java/hudson/cli/CliProtocol.java b/core/src/main/java/hudson/cli/CliProtocol.java index d663957fe9debe8998bb698ece5067fa40b410bd..cdf25b033ff048d8bb6b5696253a3ef4afcc4830 100644 --- a/core/src/main/java/hudson/cli/CliProtocol.java +++ b/core/src/main/java/hudson/cli/CliProtocol.java @@ -26,7 +26,9 @@ import java.net.Socket; * * @author Kohsuke Kawaguchi * @since 1.467 + * @deprecated Implementing Remoting-based protocol. */ +@Deprecated @Extension @Symbol("cli") public class CliProtocol extends AgentProtocol { @Inject @@ -42,7 +44,7 @@ public class CliProtocol extends AgentProtocol { @Override public String getName() { - return jenkins.CLI.DISABLED ? null : "CLI-connect"; + return jenkins.CLI.get().isEnabled() ? "CLI-connect" : null; } /** diff --git a/core/src/main/java/hudson/cli/CliProtocol2.java b/core/src/main/java/hudson/cli/CliProtocol2.java index bb14599dbfea037d7d07517d828ffb9aa7f968b9..e7d0bad71fa4ccc152bc25a7a2eaaefe3d958101 100644 --- a/core/src/main/java/hudson/cli/CliProtocol2.java +++ b/core/src/main/java/hudson/cli/CliProtocol2.java @@ -20,12 +20,14 @@ import java.security.Signature; * * @author Kohsuke Kawaguchi * @since 1.467 + * @deprecated Implementing Remoting-based protocol. */ +@Deprecated @Extension @Symbol("cli2") public class CliProtocol2 extends CliProtocol { @Override public String getName() { - return jenkins.CLI.DISABLED ? null : "CLI2-connect"; + return jenkins.CLI.get().isEnabled() ? "CLI2-connect" : null; } /** diff --git a/core/src/main/java/hudson/cli/CliTransportAuthenticator.java b/core/src/main/java/hudson/cli/CliTransportAuthenticator.java index f561a50eddf21c362f130fdf1c445f8003ceaa62..327a4e2707a1fd760ada27a3dbbd667fc8e29b36 100644 --- a/core/src/main/java/hudson/cli/CliTransportAuthenticator.java +++ b/core/src/main/java/hudson/cli/CliTransportAuthenticator.java @@ -20,7 +20,9 @@ import hudson.security.SecurityRealm; * * @author Kohsuke Kawaguchi * @since 1.419 + * @deprecated Specific to Remoting-based protocol. */ +@Deprecated public abstract class CliTransportAuthenticator implements ExtensionPoint { /** * Checks if this implementation supports the specified protocol. diff --git a/core/src/main/java/hudson/cli/ClientAuthenticationCache.java b/core/src/main/java/hudson/cli/ClientAuthenticationCache.java index fb754417861c61c3e17b3c45ae66572d4f25b1c9..16f11eb0ba5382769dd40d45b67fa4e7e50498f9 100644 --- a/core/src/main/java/hudson/cli/ClientAuthenticationCache.java +++ b/core/src/main/java/hudson/cli/ClientAuthenticationCache.java @@ -27,7 +27,9 @@ import java.util.Properties; * * @author Kohsuke Kawaguchi * @since 1.351 + * @deprecated Assumes Remoting, and vulnerable to JENKINS-12543. */ +@Deprecated public class ClientAuthenticationCache implements Serializable { /** * Where the store should be placed. diff --git a/core/src/main/java/hudson/cli/CommandDuringBuild.java b/core/src/main/java/hudson/cli/CommandDuringBuild.java index 67e7d64717f6377d1522c561e8d676bb53c707a1..17d154d6fa3531a9ec7bd3df8a92c0dbe7d132a9 100644 --- a/core/src/main/java/hudson/cli/CommandDuringBuild.java +++ b/core/src/main/java/hudson/cli/CommandDuringBuild.java @@ -36,7 +36,9 @@ import java.io.IOException; * Base class for those commands that are valid only during a build. * * @author Kohsuke Kawaguchi + * @deprecated Limited to Remoting-based protocol. */ +@Deprecated public abstract class CommandDuringBuild extends CLICommand { /** * This method makes sense only when called from within the build kicked by Jenkins. diff --git a/core/src/main/java/hudson/cli/ConsoleCommand.java b/core/src/main/java/hudson/cli/ConsoleCommand.java index f1069b857ae5eeab999863b512e33d4e8f8b407e..379cd8132be6ae36d3ba54968a2a4a65dcfafbcb 100644 --- a/core/src/main/java/hudson/cli/ConsoleCommand.java +++ b/core/src/main/java/hudson/cli/ConsoleCommand.java @@ -75,6 +75,7 @@ public class ConsoleCommand extends CLICommand { do { logText = run.getLogText(); pos = logText.writeLogTo(pos, w); + // TODO should sleep as in Run.writeWholeLogTo } while (!logText.isComplete()); } else { try (InputStream logInputStream = run.getLogInputStream()) { diff --git a/core/src/main/java/hudson/cli/HelpCommand.java b/core/src/main/java/hudson/cli/HelpCommand.java index 295e6e0e8e966f9e724d4eda231dff37b1e0952f..60fcc0970be1f6faa8badffcdfa0b1dde132a42c 100644 --- a/core/src/main/java/hudson/cli/HelpCommand.java +++ b/core/src/main/java/hudson/cli/HelpCommand.java @@ -53,7 +53,7 @@ public class HelpCommand extends CLICommand { protected int run() throws Exception { if (!Jenkins.getActiveInstance().hasPermission(Jenkins.READ)) { throw new AccessDeniedException("You must authenticate to access this Jenkins.\n" - + "Use --username/--password/--password-file parameters or login command."); + + hudson.cli.client.Messages.CLI_Usage()); } if (command != null) diff --git a/core/src/main/java/hudson/cli/InstallPluginCommand.java b/core/src/main/java/hudson/cli/InstallPluginCommand.java index c21a96628d5f55fd7ac38145c2cde2938a12d30e..e04032893625736e4544ece5ba787a09172b0e17 100644 --- a/core/src/main/java/hudson/cli/InstallPluginCommand.java +++ b/core/src/main/java/hudson/cli/InstallPluginCommand.java @@ -42,6 +42,7 @@ import java.util.HashSet; import java.util.List; import java.util.ArrayList; import java.util.Set; +import org.apache.commons.io.FileUtils; /** * Installs a plugin either from a file, an URL, or from update center. @@ -55,14 +56,15 @@ public class InstallPluginCommand extends CLICommand { return Messages.InstallPluginCommand_ShortDescription(); } - @Argument(metaVar="SOURCE",required=true,usage="If this points to a local file, that file will be installed. " + - "If this is an URL, Jenkins downloads the URL and installs that as a plugin." + - "Otherwise the name is assumed to be the short name of the plugin in the existing update center (like \"findbugs\")," + + @Argument(metaVar="SOURCE",required=true,usage="If this points to a local file (‘-remoting’ mode only), that file will be installed. " + + "If this is an URL, Jenkins downloads the URL and installs that as a plugin. " + + "If it is the string ‘=’, the file will be read from standard input of the command, and ‘-name’ must be specified. " + + "Otherwise the name is assumed to be the short name of the plugin in the existing update center (like ‘findbugs’), " + "and the plugin will be installed from the update center.") public List sources = new ArrayList(); @Option(name="-name",usage="If specified, the plugin will be installed as this short name (whereas normally the name is inferred from the source name automatically).") - public String name; + public String name; // TODO better to parse out Short-Name from the manifest and deprecate this option @Option(name="-restart",usage="Restart Jenkins upon successful installation.") public boolean restart; @@ -80,6 +82,19 @@ public class InstallPluginCommand extends CLICommand { } for (String source : sources) { + if (source.equals("=")) { + if (name == null) { + throw new IllegalArgumentException("-name required when using -source -"); + } + stdout.println(Messages.InstallPluginCommand_InstallingPluginFromStdin()); + File f = getTargetFile(name); + FileUtils.copyInputStreamToFile(stdin, f); + if (dynamicLoad) { + pm.dynamicLoad(f); + } + continue; + } + // is this a file? if (channel!=null) { FilePath f = new FilePath(channel, source); diff --git a/core/src/main/java/hudson/cli/InstallToolCommand.java b/core/src/main/java/hudson/cli/InstallToolCommand.java index c387241f5a9066aadba2166f9faa4c23f6f8efd5..ef8db698f99df0f20da2a91c5092fc8804870d93 100644 --- a/core/src/main/java/hudson/cli/InstallToolCommand.java +++ b/core/src/main/java/hudson/cli/InstallToolCommand.java @@ -48,7 +48,9 @@ import org.kohsuke.args4j.Argument; * Performs automatic tool installation on demand. * * @author Kohsuke Kawaguchi + * @deprecated Limited to Remoting-based protocol. */ +@Deprecated @Extension public class InstallToolCommand extends CLICommand { @Argument(index=0,metaVar="KIND",usage="The type of the tool to install, such as 'Ant'") diff --git a/core/src/main/java/hudson/cli/LoginCommand.java b/core/src/main/java/hudson/cli/LoginCommand.java index c27b09d5100eb03863030631d03a80b097b7ce10..8920ad41114313104b356c8e5d9687558befd9c1 100644 --- a/core/src/main/java/hudson/cli/LoginCommand.java +++ b/core/src/main/java/hudson/cli/LoginCommand.java @@ -1,6 +1,7 @@ package hudson.cli; import hudson.Extension; +import java.io.PrintStream; import jenkins.model.Jenkins; import org.acegisecurity.Authentication; import org.kohsuke.args4j.CmdLineException; @@ -10,14 +11,22 @@ import org.kohsuke.args4j.CmdLineException; * * @author Kohsuke Kawaguchi * @since 1.351 + * @deprecated Assumes Remoting, and vulnerable to JENKINS-12543. */ @Extension +@Deprecated public class LoginCommand extends CLICommand { @Override public String getShortDescription() { return Messages.LoginCommand_ShortDescription(); } + @Override + protected void printUsageSummary(PrintStream stderr) { + super.printUsageSummary(stderr); + stderr.println(Messages.LoginCommand_FullDescription()); + } + /** * If we use the stored authentication for the login command, login becomes no-op, which is clearly not what * the user has intended. diff --git a/core/src/main/java/hudson/cli/LogoutCommand.java b/core/src/main/java/hudson/cli/LogoutCommand.java index 80b2acce9b6302c569161b0f24aee72d9e2e532f..822c99d84ef1747ec60365513d4e8cce9587126d 100644 --- a/core/src/main/java/hudson/cli/LogoutCommand.java +++ b/core/src/main/java/hudson/cli/LogoutCommand.java @@ -1,13 +1,16 @@ package hudson.cli; import hudson.Extension; +import java.io.PrintStream; /** * Deletes the credential stored with the login command. * * @author Kohsuke Kawaguchi * @since 1.351 + * @deprecated See {@link LoginCommand}. */ +@Deprecated @Extension public class LogoutCommand extends CLICommand { @Override @@ -15,6 +18,12 @@ public class LogoutCommand extends CLICommand { return Messages.LogoutCommand_ShortDescription(); } + @Override + protected void printUsageSummary(PrintStream stderr) { + super.printUsageSummary(stderr); + stderr.println(Messages.LogoutCommand_FullDescription()); + } + @Override protected int run() throws Exception { ClientAuthenticationCache store = new ClientAuthenticationCache(checkChannel()); diff --git a/core/src/main/java/hudson/cli/SetBuildParameterCommand.java b/core/src/main/java/hudson/cli/SetBuildParameterCommand.java index a54f0173e6325566f3e19fb193d26c6a8cfc9af5..2dfd0d6e8319d449a07db4344f93e5f67ea68af8 100644 --- a/core/src/main/java/hudson/cli/SetBuildParameterCommand.java +++ b/core/src/main/java/hudson/cli/SetBuildParameterCommand.java @@ -15,7 +15,9 @@ import java.util.Collections; * * @author Kohsuke Kawaguchi * @since 1.514 + * @deprecated Limited to Remoting-based protocol. */ +@Deprecated @Extension public class SetBuildParameterCommand extends CommandDuringBuild { @Argument(index=0, metaVar="NAME", required=true, usage="Name of the build variable") diff --git a/core/src/main/java/hudson/cli/SetBuildResultCommand.java b/core/src/main/java/hudson/cli/SetBuildResultCommand.java index 82756472c7b3ac09f7b6276e472512d4ad8df314..ccdcc04c116e3b4eea0e7e6c794be5948bd453d8 100644 --- a/core/src/main/java/hudson/cli/SetBuildResultCommand.java +++ b/core/src/main/java/hudson/cli/SetBuildResultCommand.java @@ -33,7 +33,9 @@ import org.kohsuke.args4j.Argument; * Sets the result of the current build. Works only if invoked from within a build. * * @author Kohsuke Kawaguchi + * @deprecated Limited to Remoting-based protocol. */ +@Deprecated @Extension public class SetBuildResultCommand extends CommandDuringBuild { @Argument(metaVar="RESULT",required=true) diff --git a/core/src/main/java/hudson/cli/util/ScriptLoader.java b/core/src/main/java/hudson/cli/util/ScriptLoader.java index 8484c1e95926e09ecb20876dda6d5a3bdf0962c9..2bdbf0253da1397a63fa1b9db17dd2fa58873908 100644 --- a/core/src/main/java/hudson/cli/util/ScriptLoader.java +++ b/core/src/main/java/hudson/cli/util/ScriptLoader.java @@ -15,7 +15,9 @@ import java.net.URL; * Reads a file (either a path or URL) over a channel. * * @author vjuranek + * @deprecated Specific to Remoting-based protocol. */ +@Deprecated public class ScriptLoader extends MasterToSlaveCallable { private final String script; diff --git a/core/src/main/java/hudson/model/FileParameterDefinition.java b/core/src/main/java/hudson/model/FileParameterDefinition.java index c57d2bafc664f054b2a871240e864ada2f6eb20a..e7160796cdf7faf757a9247b24306b74b2725099 100644 --- a/core/src/main/java/hudson/model/FileParameterDefinition.java +++ b/core/src/main/java/hudson/model/FileParameterDefinition.java @@ -23,18 +23,18 @@ */ package hudson.model; -import net.sf.json.JSONObject; -import org.jenkinsci.Symbol; -import org.kohsuke.stapler.DataBoundConstructor; -import org.kohsuke.stapler.StaplerRequest; import hudson.Extension; import hudson.FilePath; import hudson.cli.CLICommand; -import org.apache.commons.fileupload.FileItem; - -import java.io.IOException; import java.io.File; +import java.io.IOException; import javax.servlet.ServletException; +import net.sf.json.JSONObject; +import org.apache.commons.fileupload.FileItem; +import org.apache.commons.io.FileUtils; +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.StaplerRequest; /** * {@link ParameterDefinition} for doing file upload. @@ -99,14 +99,22 @@ public class FileParameterDefinition extends ParameterDefinition { return possiblyPathName; } + @SuppressWarnings("deprecation") @Override public ParameterValue createValue(CLICommand command, String value) throws IOException, InterruptedException { // capture the file to the server - FilePath src = new FilePath(command.checkChannel(),value); File local = File.createTempFile("jenkins","parameter"); - src.copyTo(new FilePath(local)); + String name; + if (value.isEmpty()) { + FileUtils.copyInputStreamToFile(command.stdin, local); + name = "stdin"; + } else { + FilePath src = new FilePath(command.checkChannel(), value); + src.copyTo(new FilePath(local)); + name = src.getName(); + } - FileParameterValue p = new FileParameterValue(getName(), local, src.getName()); + FileParameterValue p = new FileParameterValue(getName(), local, name); p.setDescription(getDescription()); p.setLocation(getName()); return p; diff --git a/core/src/main/java/hudson/model/FullDuplexHttpChannel.java b/core/src/main/java/hudson/model/FullDuplexHttpChannel.java index c0fdafb56f2b71028544dfa7d591d9c2237c3c9b..70e8c269f228d71e4fe189ce3ae52b4e3a79dca3 100644 --- a/core/src/main/java/hudson/model/FullDuplexHttpChannel.java +++ b/core/src/main/java/hudson/model/FullDuplexHttpChannel.java @@ -23,142 +23,67 @@ */ package hudson.model; -import jenkins.util.SystemProperties; import hudson.remoting.Channel; import hudson.remoting.PingThread; import hudson.remoting.Channel.Mode; -import hudson.util.ChunkedOutputStream; -import hudson.util.ChunkedInputStream; -import org.kohsuke.accmod.Restricted; -import org.kohsuke.accmod.restrictions.NoExternalUse; -import org.kohsuke.stapler.StaplerRequest; -import org.kohsuke.stapler.StaplerResponse; - -import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.UUID; -import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; +import jenkins.util.FullDuplexHttpService; /** * Builds a {@link Channel} on top of two HTTP streams (one used for each direction.) * * @author Kohsuke Kawaguchi */ -abstract public class FullDuplexHttpChannel { +abstract public class FullDuplexHttpChannel extends FullDuplexHttpService { private Channel channel; - - private InputStream upload; - - private final UUID uuid; private final boolean restricted; - private boolean completed; - public FullDuplexHttpChannel(UUID uuid, boolean restricted) throws IOException { - this.uuid = uuid; + super(uuid); this.restricted = restricted; } - /** - * This is where we send the data to the client. - * - *

- * If this connection is lost, we'll abort the channel. - */ - public synchronized void download(StaplerRequest req, StaplerResponse rsp) throws InterruptedException, IOException { - rsp.setStatus(HttpServletResponse.SC_OK); - - // server->client channel. - // this is created first, and this controls the lifespan of the channel - rsp.addHeader("Transfer-Encoding", "chunked"); - OutputStream out = rsp.getOutputStream(); - if (DIY_CHUNKING) out = new ChunkedOutputStream(out); - - // send something out so that the client will see the HTTP headers - out.write("Starting HTTP duplex channel".getBytes()); - out.flush(); - - {// wait until we have the other channel - long end = System.currentTimeMillis() + CONNECTION_TIMEOUT; - while (upload == null && System.currentTimeMillis() + * If this connection is lost, we'll abort the channel. + */ + public synchronized void download(StaplerRequest req, StaplerResponse rsp) throws InterruptedException, IOException { + rsp.setStatus(HttpServletResponse.SC_OK); + + // server->client channel. + // this is created first, and this controls the lifespan of the channel + rsp.addHeader("Transfer-Encoding", "chunked"); + OutputStream out = rsp.getOutputStream(); + if (DIY_CHUNKING) { + out = new ChunkedOutputStream(out); + } + + // send something out so that the client will see the HTTP headers + out.write(0); + out.flush(); + + {// wait until we have the other channel + long end = System.currentTimeMillis() + CONNECTION_TIMEOUT; + while (upload == null && System.currentTimeMillis() < end) { + wait(1000); + } + + if (upload == null) { + throw new IOException("HTTP full-duplex channel timeout: " + uuid); + } + } + + try { + run(upload, out); + } finally { + // publish that we are done + completed = true; + notify(); + } + } + + protected abstract void run(InputStream upload, OutputStream download) throws IOException, InterruptedException; + + /** + * This is where we receive inputs from the client. + */ + public synchronized void upload(StaplerRequest req, StaplerResponse rsp) throws InterruptedException, IOException { + rsp.setStatus(HttpServletResponse.SC_OK); + InputStream in = req.getInputStream(); + if (DIY_CHUNKING) { + in = new ChunkedInputStream(in); + } + + // publish the upload channel + upload = in; + notify(); + + // wait until we are done + while (!completed) { + wait(); + } + } + + /** + * HTTP response that allows a client to use this service. + */ + public static abstract class Response extends HttpResponses.HttpResponseException { + + private final Map services; + + /** + * @param services a cross-request cache of services, to correlate the + * upload and download connections + */ + protected Response(Map services) { + this.services = services; + } + + @Override + public void generateResponse(StaplerRequest req, StaplerResponse rsp, Object node) throws IOException, ServletException { + try { + // do not require any permission to establish a CLI connection + // the actual authentication for the connecting Channel is done by CLICommand + + UUID uuid = UUID.fromString(req.getHeader("Session")); + rsp.setHeader("Hudson-Duplex", "true"); // set the header so that the client would know + + if (req.getHeader("Side").equals("download")) { + FullDuplexHttpService service = createService(req, uuid); + services.put(uuid, service); + try { + service.download(req, rsp); + } finally { + services.remove(uuid); + } + } else { + services.get(uuid).upload(req, rsp); + } + } catch (InterruptedException e) { + throw new IOException(e); + } + } + + protected abstract FullDuplexHttpService createService(StaplerRequest req, UUID uuid) throws IOException, InterruptedException; + + } + +} diff --git a/core/src/main/resources/hudson/cli/Messages.properties b/core/src/main/resources/hudson/cli/Messages.properties index 997193ad61b509d453c58ff7de0ad82971ad0cc1..8b9c933e6dc90092f473b1a0cfbf71842e02acba 100644 --- a/core/src/main/resources/hudson/cli/Messages.properties +++ b/core/src/main/resources/hudson/cli/Messages.properties @@ -1,6 +1,7 @@ InstallPluginCommand.DidYouMean={0} looks like a short plugin name. Did you mean \u2018{1}\u2019? InstallPluginCommand.InstallingFromUpdateCenter=Installing {0} from update center InstallPluginCommand.InstallingPluginFromLocalFile=Installing a plugin from local file: {0} +InstallPluginCommand.InstallingPluginFromStdin=Installing a plugin from standard input InstallPluginCommand.InstallingPluginFromUrl=Installing a plugin from {0} InstallPluginCommand.NoUpdateCenterDefined=Note that no update center is defined in this Jenkins. InstallPluginCommand.NoUpdateDataRetrieved=No update center data is retrieved yet from: {0} @@ -33,7 +34,7 @@ HelpCommand.ShortDescription=\ InstallPluginCommand.ShortDescription=\ Installs a plugin either from a file, an URL, or from update center. InstallToolCommand.ShortDescription=\ - Performs automatic tool installation, and print its location to stdout. Can be only called from inside a build. + Performs automatic tool installation, and print its location to stdout. Can be only called from inside a build. [deprecated] ListChangesCommand.ShortDescription=\ Dumps the changelog for the specified build(s). ListJobsCommand.ShortDescription=\ @@ -41,16 +42,27 @@ ListJobsCommand.ShortDescription=\ ListPluginsCommand.ShortDescription=\ Outputs a list of installed plugins. LoginCommand.ShortDescription=\ - Saves the current credential to allow future commands to run without explicit credential information. + Saves the current credentials to allow future commands to run without explicit credential information. [deprecated] +LoginCommand.FullDescription=\ + Depending on the security realm, you will need to pass something like:\n\ + --username USER [ --password PASS | --password-file FILE ]\n\ + May not be supported in some security realms, such as single-sign-on.\n\ + Pair with the logout command.\n\ + The same options can be used on any other command, but unlike other generic CLI options,\n\ + these come *after* the command name.\n\ + Whether stored or not, this authentication mode will not let you refer to (e.g.) jobs as arguments\n\ + if Jenkins denies anonymous users Overall/Read and (e.g.) Job/Read.\n\ + *Deprecated* in favor of -auth. LogoutCommand.ShortDescription=\ - Deletes the credential stored with the login command. + Deletes the credentials stored with the login command. [deprecated] +LogoutCommand.FullDescription=*Deprecated* in favor of -auth. MailCommand.ShortDescription=\ Reads stdin and sends that out as an e-mail. SetBuildDescriptionCommand.ShortDescription=\ Sets the description of a build. -SetBuildParameterCommand.ShortDescription=Update/set the build parameter of the current build in progress. +SetBuildParameterCommand.ShortDescription=Update/set the build parameter of the current build in progress. [deprecated] SetBuildResultCommand.ShortDescription=\ - Sets the result of the current build. Works only if invoked from within a build. + Sets the result of the current build. Works only if invoked from within a build. [deprecated] RemoveJobFromViewCommand.ShortDescription=\ Removes jobs from view. VersionCommand.ShortDescription=\ diff --git a/core/src/main/resources/jenkins/CLI/WarnWhenEnabled/message.jelly b/core/src/main/resources/jenkins/CLI/WarnWhenEnabled/message.jelly new file mode 100644 index 0000000000000000000000000000000000000000..5f93d73ee710a380c262e9e00b4e2047347af8de --- /dev/null +++ b/core/src/main/resources/jenkins/CLI/WarnWhenEnabled/message.jelly @@ -0,0 +1,37 @@ + + + + + +

+
+
+ + +
+ ${%blurb} +
+
+ diff --git a/core/src/main/resources/jenkins/CLI/WarnWhenEnabled/message.properties b/core/src/main/resources/jenkins/CLI/WarnWhenEnabled/message.properties new file mode 100644 index 0000000000000000000000000000000000000000..2b6c3980d584a874806a5d931e1a6d9aa35b7332 --- /dev/null +++ b/core/src/main/resources/jenkins/CLI/WarnWhenEnabled/message.properties @@ -0,0 +1,26 @@ +# The MIT License +# +# Copyright 2017 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. + +blurb=\ + Allowing Jenkins CLI to work in -remoting mode is considered dangerous and usually unnecessary. \ + You are advised to disable this mode. \ + Please refer to the CLI documentation for details. diff --git a/core/src/main/resources/jenkins/CLI/config.jelly b/core/src/main/resources/jenkins/CLI/config.jelly new file mode 100644 index 0000000000000000000000000000000000000000..001bca27db7e562028d9d47f3eb31e9e6edc2fea --- /dev/null +++ b/core/src/main/resources/jenkins/CLI/config.jelly @@ -0,0 +1,33 @@ + + + + + + + + + + + diff --git a/core/src/main/resources/jenkins/CLI/help-enabled.html b/core/src/main/resources/jenkins/CLI/help-enabled.html new file mode 100644 index 0000000000000000000000000000000000000000..8301e1fdebc15aa5f1d5c625961c1215ee18a1b5 --- /dev/null +++ b/core/src/main/resources/jenkins/CLI/help-enabled.html @@ -0,0 +1,8 @@ +
+ Whether to enable the historical Jenkins CLI mode over remoting + (-remoting option in the client). + While this may be necessary to support certain commands or command options, + it is considered intrinsically insecure. + (-http mode is always available, + and -ssh mode is available whenever the SSH service is enabled.) +
diff --git a/pom.xml b/pom.xml index 1703c8444e1074acd70d9fcd09a0445738a17210..f7ef5f54b81cbcb9ce4c4355edecad32dc5f1d06 100644 --- a/pom.xml +++ b/pom.xml @@ -108,12 +108,7 @@ THE SOFTWARE. repo.jenkins-ci.org http://repo.jenkins-ci.org/public/ - - true - - - false - + @@ -121,12 +116,6 @@ THE SOFTWARE. repo.jenkins-ci.org http://repo.jenkins-ci.org/public/ - - true - - - false - @@ -202,11 +191,6 @@ THE SOFTWARE. slf4j-api ${slf4jVersion}
- - org.slf4j - slf4j-nop - ${slf4jVersion} - org.slf4j slf4j-jdk14 @@ -392,6 +376,7 @@ THE SOFTWARE. 3600 true + false diff --git a/test/pom.xml b/test/pom.xml index 0106d58916ff02692986d42d863ecb9a8b538898..f87e530d72f9033976d0a2d5052dda5cd943c597 100644 --- a/test/pom.xml +++ b/test/pom.xml @@ -40,7 +40,6 @@ THE SOFTWARE. 2 false - false @@ -53,12 +52,6 @@ THE SOFTWARE. jenkins-war ${project.version} war-for-test - - - org.jenkins-ci.modules - sshd - - ${project.groupId} diff --git a/test/src/test/groovy/hudson/cli/SetBuildParameterCommandTest.groovy b/test/src/test/groovy/hudson/cli/SetBuildParameterCommandTest.groovy index b082cd03e5388a55a0335d0b7ab61063f2eb0412..dcfba02ed84075531e8d038ce4faec03fa7a28e0 100644 --- a/test/src/test/groovy/hudson/cli/SetBuildParameterCommandTest.groovy +++ b/test/src/test/groovy/hudson/cli/SetBuildParameterCommandTest.groovy @@ -14,8 +14,10 @@ import hudson.tasks.Builder import hudson.tasks.Shell import jenkins.model.JenkinsLocationConfiguration import org.junit.Assert +import org.junit.ClassRule import org.junit.Rule import org.junit.Test +import org.jvnet.hudson.test.BuildWatcher import org.jvnet.hudson.test.JenkinsRule import org.jvnet.hudson.test.TestBuilder @@ -26,6 +28,9 @@ public class SetBuildParameterCommandTest { @Rule public JenkinsRule j = new JenkinsRule(); + @ClassRule + public static BuildWatcher buildWatcher = new BuildWatcher(); + @Test public void cli() { JenkinsLocationConfiguration.get().url = j.URL; @@ -42,9 +47,9 @@ public class SetBuildParameterCommandTest { }); List pd = [new StringParameterDefinition("a", ""), new StringParameterDefinition("b", "")]; p.addProperty(new ParametersDefinitionProperty(pd)) - p.buildersList.add(createScriptBuilder("java -jar cli.jar set-build-parameter a b")) - p.buildersList.add(createScriptBuilder("java -jar cli.jar set-build-parameter a x")) - p.buildersList.add(createScriptBuilder("java -jar cli.jar set-build-parameter b y")) + p.buildersList.add(createScriptBuilder("java -jar cli.jar -remoting -noKeyAuth set-build-parameter a b")) + p.buildersList.add(createScriptBuilder("java -jar cli.jar -remoting -noKeyAuth set-build-parameter a x")) + p.buildersList.add(createScriptBuilder("java -jar cli.jar -remoting -noKeyAuth set-build-parameter b y")) def r = [:]; @@ -54,11 +59,12 @@ public class SetBuildParameterCommandTest { assert r.equals(["a":"x", "b":"y"]); if (Functions.isWindows()) { - p.buildersList.add(new BatchFile("set BUILD_NUMBER=1\r\njava -jar cli.jar set-build-parameter a b")) + p.buildersList.add(new BatchFile("set BUILD_NUMBER=1\r\njava -jar cli.jar -remoting -noKeyAuth set-build-parameter a b")) } else { - p.buildersList.add(new Shell("BUILD_NUMBER=1 java -jar cli.jar set-build-parameter a b")) + p.buildersList.add(new Shell("BUILD_NUMBER=1 java -jar cli.jar -remoting -noKeyAuth set-build-parameter a b")) } def b2 = j.assertBuildStatus(Result.FAILURE, p.scheduleBuild2(0).get()); + j.assertLogContains("#1 is not currently being built", b2) r = [:]; b.getAction(ParametersAction.class).parameters.each { v -> r[v.name]=v.value } assert r.equals(["a":"x", "b":"y"]); diff --git a/test/src/test/java/hudson/cli/BuildCommand2Test.java b/test/src/test/java/hudson/cli/BuildCommand2Test.java new file mode 100644 index 0000000000000000000000000000000000000000..1f5366e1bbfc6bcad7170bb31e8574ef66023393 --- /dev/null +++ b/test/src/test/java/hudson/cli/BuildCommand2Test.java @@ -0,0 +1,74 @@ +/* + * The MIT License + * + * Copyright 2017 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 hudson.cli; + +import hudson.Launcher; +import hudson.model.AbstractBuild; +import hudson.model.BuildListener; +import hudson.model.FileParameterDefinition; +import hudson.model.FreeStyleBuild; +import hudson.model.FreeStyleProject; +import hudson.model.ParametersDefinitionProperty; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import org.junit.ClassRule; +import org.junit.Test; +import static org.junit.Assert.*; +import org.junit.Rule; +import org.jvnet.hudson.test.BuildWatcher; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.TestBuilder; + +public class BuildCommand2Test { + + @ClassRule + public static BuildWatcher buildWatcher = new BuildWatcher(); + + @Rule + public JenkinsRule r = new JenkinsRule(); + + @Issue("JENKINS-41745") + @Test + public void fileParameter() throws Exception { + FreeStyleProject p = r.createFreeStyleProject("myjob"); + p.addProperty(new ParametersDefinitionProperty(new FileParameterDefinition("file", null))); + p.getBuildersList().add(new TestBuilder() { + @Override + public boolean perform(AbstractBuild build, Launcher launcher, BuildListener listener) throws InterruptedException, IOException { + listener.getLogger().println("Found in my workspace: " + build.getWorkspace().child("file").readToString()); + return true; + } + }); + assertThat(new CLICommandInvoker(r, "build"). + withStdin(new ByteArrayInputStream("uploaded content here".getBytes())). + invokeWithArgs("-f", "-p", "file=", "myjob"), + CLICommandInvoker.Matcher.succeeded()); + FreeStyleBuild b = p.getBuildByNumber(1); + assertNotNull(b); + r.assertLogContains("uploaded content here", b); + } + +} diff --git a/test/src/test/java/hudson/cli/CLIActionTest.java b/test/src/test/java/hudson/cli/CLIActionTest.java index 8a808673a48311bc4233df0ca67c2d925ec70684..62ab20ce38581db792df980c955d104eb0c14521 100644 --- a/test/src/test/java/hudson/cli/CLIActionTest.java +++ b/test/src/test/java/hudson/cli/CLIActionTest.java @@ -4,29 +4,64 @@ import com.gargoylesoftware.htmlunit.HttpMethod; import com.gargoylesoftware.htmlunit.Page; import com.gargoylesoftware.htmlunit.WebRequest; import com.gargoylesoftware.htmlunit.WebResponse; +import com.google.common.collect.Lists; import hudson.Functions; +import hudson.Launcher; +import hudson.Proc; +import hudson.model.Item; +import hudson.model.User; import hudson.remoting.Channel; import hudson.remoting.ChannelBuilder; +import hudson.util.ProcessTree; +import hudson.util.StreamTaskListener; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.io.PrintWriter; +import java.lang.reflect.Field; import java.net.HttpURLConnection; import java.net.URL; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import jenkins.model.Jenkins; +import jenkins.security.ApiTokenProperty; +import jenkins.util.Timer; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.output.TeeOutputStream; import org.codehaus.groovy.runtime.Security218; import static org.hamcrest.Matchers.containsString; import static org.junit.Assert.*; import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import org.jvnet.hudson.test.Issue; import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.LoggerRule; +import org.jvnet.hudson.test.MockAuthorizationStrategy; +import org.jvnet.hudson.test.TestExtension; import org.jvnet.hudson.test.recipes.PresetData; import org.jvnet.hudson.test.recipes.PresetData.DataSet; public class CLIActionTest { @Rule public JenkinsRule j = new JenkinsRule(); + { // authentication() can take a while on a loaded machine + j.timeout = System.getProperty("maven.surefire.debug") == null ? 300 : 0; + } + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Rule + public LoggerRule logging = new LoggerRule(); private ExecutorService pool; @@ -37,6 +72,7 @@ public class CLIActionTest { public void testDuplexHttp() throws Exception { pool = Executors.newCachedThreadPool(); try { + @SuppressWarnings("deprecation") // to verify compatibility of original constructor FullDuplexHttpStream con = new FullDuplexHttpStream(new URL(j.getURL(), "cli"), null); Channel ch = new ChannelBuilder("test connection", pool).build(con.getInputStream(), con.getOutputStream()); ch.close(); @@ -49,7 +85,7 @@ public class CLIActionTest { public void security218() throws Exception { pool = Executors.newCachedThreadPool(); try { - FullDuplexHttpStream con = new FullDuplexHttpStream(new URL(j.getURL(), "cli"), null); + FullDuplexHttpStream con = new FullDuplexHttpStream(j.getURL(), "cli", null); Channel ch = new ChannelBuilder("test connection", pool).build(con.getInputStream(), con.getOutputStream()); ch.call(new Security218()); fail("Expected the call to be rejected"); @@ -61,7 +97,7 @@ public class CLIActionTest { } - @SuppressWarnings({"unchecked", "rawtypes"}) // intentionally passing an unreifiable argument here + @SuppressWarnings({"unchecked", "rawtypes", "deprecation"}) // intentionally passing an unreifiable argument here; Remoting-based constructor intentional @Test public void security218_take2() throws Exception { pool = Executors.newCachedThreadPool(); @@ -102,4 +138,165 @@ public class CLIActionTest { wc.goTo("cli"); } + @Issue({"JENKINS-12543", "JENKINS-41745"}) + @Test + public void authentication() throws Exception { + logging.record(PlainCLIProtocol.class, Level.FINE); + File jar = tmp.newFile("jenkins-cli.jar"); + FileUtils.copyURLToFile(j.jenkins.getJnlpJars("jenkins-cli.jar").getURL(), jar); + j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); + j.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy().grant(Jenkins.ADMINISTER).everywhere().to(ADMIN)); + j.createFreeStyleProject("p"); + // CLICommand with @Argument: + assertExitCode(3, false, jar, "-remoting", "get-job", "p"); // IllegalArgumentException from GenericItemOptionHandler + assertExitCode(3, false, jar, "get-job", "p"); // ditto under new protocol + assertExitCode(3, false, jar, "-remoting", "get-job", "--username", ADMIN, "--password", ADMIN, "p"); // JENKINS-12543: too late + assertExitCode(3, false, jar, "get-job", "--username", ADMIN, "--password", ADMIN, "p"); // same + assertExitCode(0, false, jar, "-remoting", "login", "--username", ADMIN, "--password", ADMIN); + try { + assertExitCode(3, false, jar, "-remoting", "get-job", "p"); // ClientAuthenticationCache also used too late + } finally { + assertExitCode(0, false, jar, "-remoting", "logout"); + } + assertExitCode(3, true, jar, "-remoting", "get-job", "p"); // does not work with API tokens + assertExitCode(0, true, jar, "get-job", "p"); // but does under new protocol + // @CLIMethod: + assertExitCode(6, false, jar, "-remoting", "disable-job", "p"); // AccessDeniedException from CLIRegisterer? + assertExitCode(6, false, jar, "disable-job", "p"); + assertExitCode(0, false, jar, "-remoting", "disable-job", "--username", ADMIN, "--password", ADMIN, "p"); // works from CliAuthenticator + assertExitCode(0, false, jar, "disable-job", "--username", ADMIN, "--password", ADMIN, "p"); + assertExitCode(0, false, jar, "-remoting", "login", "--username", ADMIN, "--password", ADMIN); + try { + assertExitCode(0, false, jar, "-remoting", "disable-job", "p"); // or from ClientAuthenticationCache + } finally { + assertExitCode(0, false, jar, "-remoting", "logout"); + } + assertExitCode(6, true, jar, "-remoting", "disable-job", "p"); + 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, "-remoting", "get-job", "p"); // AccessDeniedException from AbstractItem.writeConfigDotXml + assertExitCode(6, false, jar, "get-job", "p"); + assertExitCode(0, false, jar, "-remoting", "get-job", "--username", ADMIN, "--password", ADMIN, "p"); + assertExitCode(0, false, jar, "get-job", "--username", ADMIN, "--password", ADMIN, "p"); + assertExitCode(0, false, jar, "-remoting", "login", "--username", ADMIN, "--password", ADMIN); + try { + assertExitCode(0, false, jar, "-remoting", "get-job", "p"); + } finally { + assertExitCode(0, false, jar, "-remoting", "logout"); + } + assertExitCode(6, true, jar, "-remoting", "get-job", "p"); // does not work with API tokens + assertExitCode(0, true, jar, "get-job", "p"); // but does under new protocol + assertExitCode(6, false, jar, "-remoting", "disable-job", "p"); // AccessDeniedException from AbstractProject.doDisable + assertExitCode(6, false, jar, "disable-job", "p"); + assertExitCode(0, false, jar, "-remoting", "disable-job", "--username", ADMIN, "--password", ADMIN, "p"); + assertExitCode(0, false, jar, "disable-job", "--username", ADMIN, "--password", ADMIN, "p"); + assertExitCode(0, false, jar, "-remoting", "login", "--username", ADMIN, "--password", ADMIN); + try { + assertExitCode(0, false, jar, "-remoting", "disable-job", "p"); + } finally { + assertExitCode(0, false, jar, "-remoting", "logout"); + } + assertExitCode(6, true, jar, "-remoting", "disable-job", "p"); + assertExitCode(0, true, jar, "disable-job", "p"); + // Show that API tokens do work in Remoting-over-HTTP mode (just not over the JNLP port): + j.jenkins.setSlaveAgentPort(-1); + assertExitCode(0, true, jar, "-remoting", "get-job", "p"); + assertExitCode(0, true, jar, "-remoting", "disable-job", "p"); + } + + 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"); + if (useApiToken) { + commands.add("-auth"); + commands.add(ADMIN + ":" + User.get(ADMIN).getProperty(ApiTokenProperty.class).getApiToken()); + } + commands.addAll(Arrays.asList(args)); + final Launcher.LocalLauncher launcher = new Launcher.LocalLauncher(StreamTaskListener.fromStderr()); + final Proc proc = launcher.launch().cmds(commands).stdout(System.out).stderr(System.err).start(); + if (!Functions.isWindows()) { + // Try to get a thread dump of the client if it hangs. + Timer.get().schedule(new Runnable() { + @Override + public void run() { + try { + if (proc.isAlive()) { + Field procF = Proc.LocalProc.class.getDeclaredField("proc"); + procF.setAccessible(true); + ProcessTree.OSProcess osp = ProcessTree.get().get((Process) procF.get(proc)); + if (osp != null) { + launcher.launch().cmds("kill", "-QUIT", Integer.toString(osp.getPid())).stdout(System.out).stderr(System.err).join(); + } + } + } catch (Exception x) { + throw new AssertionError(x); + } + } + }, 1, TimeUnit.MINUTES); + } + assertEquals(code, proc.join()); + } + + @Issue("JENKINS-41745") + @Test + public void encodingAndLocale() throws Exception { + File jar = tmp.newFile("jenkins-cli.jar"); + FileUtils.copyURLToFile(j.jenkins.getJnlpJars("jenkins-cli.jar").getURL(), jar); + 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"). + 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!) + // -ssh mode does not pass client locale or encoding + } + + @Issue("JENKINS-41745") + @Test + public void interleavedStdio() throws Exception { + logging.record(PlainCLIProtocol.class, Level.FINE); + File jar = tmp.newFile("jenkins-cli.jar"); + FileUtils.copyURLToFile(j.jenkins.getJnlpJars("jenkins-cli.jar").getURL(), jar); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + PipedInputStream pis = new PipedInputStream(); + 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"). + 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); + } + pw.println("11 * 11"); + 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); + } + pw.println(":q"); + assertEquals(0, proc.join()); + } + + @TestExtension("encodingAndLocale") + public static class TestDiagnosticCommand extends CLICommand { + + @Override + public String getShortDescription() { + return "Print information about the command environment."; + } + + @Override + protected int run() throws Exception { + stdout.println("encoding=" + getClientCharset() + " locale=" + locale); + return 0; + } + + } + } diff --git a/test/src/test/java/hudson/cli/CLITest.java b/test/src/test/java/hudson/cli/CLITest.java new file mode 100644 index 0000000000000000000000000000000000000000..19abf44b8fd5e13da7db97f2204b36be5ab9f626 --- /dev/null +++ b/test/src/test/java/hudson/cli/CLITest.java @@ -0,0 +1,152 @@ +/* + * The MIT License + * + * Copyright 2017 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 hudson.cli; + +import com.google.common.collect.Lists; +import hudson.Functions; +import hudson.Launcher; +import hudson.Proc; +import hudson.model.FreeStyleProject; +import hudson.model.User; +import hudson.util.StreamTaskListener; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; +import jenkins.model.Jenkins; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; +import org.apache.commons.io.output.TeeOutputStream; +import static org.hamcrest.Matchers.*; +import org.jenkinsci.main.modules.cli.auth.ssh.UserPropertyImpl; +import org.jenkinsci.main.modules.sshd.SSHD; +import static org.junit.Assert.*; +import static org.junit.Assume.*; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +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.MockAuthorizationStrategy; +import org.jvnet.hudson.test.SleepBuilder; + +public class CLITest { + + @ClassRule + public static BuildWatcher buildWatcher = new BuildWatcher(); + + @Rule + public JenkinsRule r = new JenkinsRule(); + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + /** Sets up a fake {@code user.home} so that tests {@code -ssh} mode does not get confused by the developer’s real {@code ~/.ssh/known_hosts}. */ + private File tempHome() throws IOException { + File home = tmp.newFolder(); + // Seems it gets created automatically but with inappropriate permissions: + File known_hosts = new File(new File(home, ".ssh"), "known_hosts"); + assumeTrue(known_hosts.getParentFile().mkdir()); + assumeTrue(known_hosts.createNewFile()); + assumeTrue(known_hosts.setWritable(false, false)); + assumeTrue(known_hosts.setWritable(true, true)); + try { + Files.getOwner(known_hosts.toPath()); + } catch (IOException x) { + assumeNoException("Sometimes on Windows KnownHostsServerKeyVerifier.acceptIncompleteHostKeys says WARNING: Failed (FileSystemException) to reload server keys from …\\\\.ssh\\\\known_hosts: … Incorrect function.", x); + } + /* TODO impossible to do this until the bundled sshd module uses a sufficiently new version of sshd-core: + assumeThat("or on Windows DefaultKnownHostsServerKeyVerifier.reloadKnownHosts says invalid file permissions: Owner violation (Administrators)", + ModifiableFileWatcher.validateStrictConfigFilePermissions(known_hosts.toPath()), nullValue()); + */ + assumeFalse(Functions.isWindows()); // TODO can remove when above check is restored + return home; + } + + @Issue("JENKINS-41745") + @Test + public void strictHostKey() throws Exception { + File home = tempHome(); + r.jenkins.setSecurityRealm(r.createDummySecurityRealm()); + r.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy().grant(Jenkins.ADMINISTER).everywhere().to("admin")); + SSHD.get().setPort(0); + File jar = tmp.newFile("jenkins-cli.jar"); + FileUtils.copyURLToFile(r.jenkins.getJnlpJars("jenkins-cli.jar").getURL(), jar); + File privkey = tmp.newFile("id_rsa"); + FileUtils.copyURLToFile(CLITest.class.getResource("id_rsa"), privkey); + User.get("admin").addProperty(new UserPropertyImpl(IOUtils.toString(CLITest.class.getResource("id_rsa.pub")))); + assertNotEquals(0, new Launcher.LocalLauncher(StreamTaskListener.fromStderr()).launch().cmds( + "java", "-Duser.home=" + home, "-jar", jar.getAbsolutePath(), "-s", r.getURL().toString(), "-ssh", "-user", "admin", "-i", privkey.getAbsolutePath(), "-strictHostKey", "who-am-i" + ).stdout(System.out).stderr(System.err).join()); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + assertEquals(0, new Launcher.LocalLauncher(StreamTaskListener.fromStderr()).launch().cmds( + "java", "-Duser.home=" + home, "-jar", jar.getAbsolutePath(), "-s", r.getURL().toString(), "-ssh", "-user", "admin", "-i", privkey.getAbsolutePath(), "-logger", "FINEST", "who-am-i" + ).stdout(baos).stderr(System.err).join()); + assertThat(baos.toString(), containsString("Authenticated as: admin")); + baos = new ByteArrayOutputStream(); + assertEquals(0, new Launcher.LocalLauncher(StreamTaskListener.fromStderr()).launch().cmds( + "java", "-Duser.home=" + home, "-jar", jar.getAbsolutePath(), "-s", r.getURL().toString()./* just checking */replaceFirst("/$", ""), "-ssh", "-user", "admin", "-i", privkey.getAbsolutePath(), "-strictHostKey", "who-am-i" + ).stdout(baos).stderr(System.err).join()); + assertThat(baos.toString(), containsString("Authenticated as: admin")); + } + + @Issue("JENKINS-41745") + @Test + public void interrupt() throws Exception { + File home = tempHome(); + r.jenkins.setSecurityRealm(r.createDummySecurityRealm()); + r.jenkins.setAuthorizationStrategy(new MockAuthorizationStrategy().grant(Jenkins.ADMINISTER).everywhere().to("admin")); + SSHD.get().setPort(0); + File jar = tmp.newFile("jenkins-cli.jar"); + FileUtils.copyURLToFile(r.jenkins.getJnlpJars("jenkins-cli.jar").getURL(), jar); + File privkey = tmp.newFile("id_rsa"); + FileUtils.copyURLToFile(CLITest.class.getResource("id_rsa"), privkey); + User.get("admin").addProperty(new UserPropertyImpl(IOUtils.toString(CLITest.class.getResource("id_rsa.pub")))); + FreeStyleProject p = r.createFreeStyleProject("p"); + p.getBuildersList().add(new SleepBuilder(TimeUnit.MINUTES.toMillis(5))); + doInterrupt(jar, home, p, "-remoting", "-i", privkey.getAbsolutePath()); + doInterrupt(jar, home, p, "-ssh", "-user", "admin", "-i", privkey.getAbsolutePath()); + doInterrupt(jar, home, p, "-http", "-auth", "admin:admin"); + } + private void doInterrupt(File jar, File home, FreeStyleProject p, String... modeArgs) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + List args = Lists.newArrayList("java", "-Duser.home=" + home, "-jar", jar.getAbsolutePath(), "-s", r.getURL().toString()); + args.addAll(Arrays.asList(modeArgs)); + args.addAll(Arrays.asList("build", "-s", "-v", "p")); + Proc proc = new Launcher.LocalLauncher(StreamTaskListener.fromStderr()).launch().cmds(args).stdout(new TeeOutputStream(baos, System.out)).stderr(System.err).start(); + while (!baos.toString().contains("Sleeping ")) { + Thread.sleep(100); + } + System.err.println("Killing client"); + proc.kill(); + r.waitForCompletion(p.getLastBuild()); + } + +} diff --git a/test/src/test/java/hudson/cli/GetJobCommandTest.java b/test/src/test/java/hudson/cli/GetJobCommandTest.java index 8a52cfed0ccb2f41ae4d6d14eeb7ba59b575dd27..878cb022484c9676f5410262663bb45060361806 100644 --- a/test/src/test/java/hudson/cli/GetJobCommandTest.java +++ b/test/src/test/java/hudson/cli/GetJobCommandTest.java @@ -47,6 +47,7 @@ public class GetJobCommandTest { FreeStyleProject p = d.createProject(FreeStyleProject.class, "p"); ByteArrayOutputStream out = new ByteArrayOutputStream(); PrintStream outS = new PrintStream(out); + // TODO switch to CLICommandInvoker int result = new GetJobCommand().main(Collections.singletonList("d/p"), Locale.ENGLISH, new NullInputStream(0), outS, outS); outS.flush(); String output = out.toString(); diff --git a/test/src/test/java/hudson/cli/InstallPluginCommandTest.java b/test/src/test/java/hudson/cli/InstallPluginCommandTest.java new file mode 100644 index 0000000000000000000000000000000000000000..c84031bc82dff43007bc6c03c4ea7f69091cb624 --- /dev/null +++ b/test/src/test/java/hudson/cli/InstallPluginCommandTest.java @@ -0,0 +1,49 @@ +/* + * The MIT License + * + * Copyright 2017 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 hudson.cli; + +import org.junit.Test; +import static org.junit.Assert.*; +import org.junit.Rule; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; + +public class InstallPluginCommandTest { + + @Rule + public JenkinsRule r = new JenkinsRule(); + + @Issue("JENKINS-41745") + @Test + public void fromStdin() throws Exception { + assertNull(r.jenkins.getPluginManager().getPlugin("token-macro")); + assertThat(new CLICommandInvoker(r, "install-plugin"). + withStdin(InstallPluginCommandTest.class.getResourceAsStream("/plugins/token-macro.hpi")). + invokeWithArgs("-name", "token-macro", "-deploy", "="), + CLICommandInvoker.Matcher.succeeded()); + assertNotNull(r.jenkins.getPluginManager().getPlugin("token-macro")); + } + +} diff --git a/test/src/test/java/hudson/model/ComputerSetTest.java b/test/src/test/java/hudson/model/ComputerSetTest.java index 56878bf4cda64f08e3b3bdfeedffb893aadb3734..38418e9cc00cc175a3bb909f3ca3e0ab7bb95e84 100644 --- a/test/src/test/java/hudson/model/ComputerSetTest.java +++ b/test/src/test/java/hudson/model/ComputerSetTest.java @@ -23,18 +23,13 @@ */ package hudson.model; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.contains; -import static org.hamcrest.Matchers.containsInAnyOrder; -import static org.hamcrest.Matchers.empty; -import static org.hamcrest.Matchers.is; -import static org.junit.Assert.assertTrue; - -import hudson.cli.CLI; +import com.gargoylesoftware.htmlunit.html.HtmlForm; +import hudson.cli.CLICommandInvoker; import hudson.slaves.DumbSlave; +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.*; import org.junit.Rule; import org.junit.Test; -import com.gargoylesoftware.htmlunit.html.HtmlForm; import org.jvnet.hudson.test.Issue; import org.jvnet.hudson.test.JenkinsRule; import org.jvnet.hudson.test.JenkinsRule.WebClient; @@ -69,14 +64,12 @@ public class ComputerSetTest { public void nodeOfflineCli() throws Exception { DumbSlave s = j.createSlave(); - try (CLI cli = new CLI(j.getURL())) { - assertTrue(cli.execute("wait-node-offline","xxx")!=0); - assertTrue(cli.execute("wait-node-online",s.getNodeName())==0); + assertThat(new CLICommandInvoker(j, "wait-node-offline").invokeWithArgs("xxx"), CLICommandInvoker.Matcher.failedWith(/* IllegalArgumentException from NodeOptionHandler */ 3)); + assertThat(new CLICommandInvoker(j, "wait-node-online").invokeWithArgs(s.getNodeName()), CLICommandInvoker.Matcher.succeededSilently()); - s.toComputer().disconnect().get(); + s.toComputer().disconnect(null).get(); - assertTrue(cli.execute("wait-node-offline",s.getNodeName())==0); - } + assertThat(new CLICommandInvoker(j, "wait-node-offline").invokeWithArgs(s.getNodeName()), CLICommandInvoker.Matcher.succeededSilently()); } @Test diff --git a/test/src/test/java/hudson/model/listeners/ItemListenerTest.java b/test/src/test/java/hudson/model/listeners/ItemListenerTest.java index bf19e6ea0d513c78cdf55a9301f05cb01d3a41c0..85d3160154a9fcc2a9c4df416b96181506f8c08b 100644 --- a/test/src/test/java/hudson/model/listeners/ItemListenerTest.java +++ b/test/src/test/java/hudson/model/listeners/ItemListenerTest.java @@ -23,21 +23,15 @@ */ package hudson.model.listeners; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; - -import hudson.cli.CLI; +import hudson.cli.CLICommandInvoker; import hudson.model.Item; +import java.io.ByteArrayInputStream; +import static org.junit.Assert.*; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.jvnet.hudson.test.JenkinsRule; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.PrintStream; -import java.util.Arrays; - /** * Tests for ItemListener events. * @author Alan.Harder@sun.com @@ -64,16 +58,11 @@ public class ItemListenerTest { @Test public void onCreatedViaCLI() throws Exception { - ByteArrayOutputStream buf = new ByteArrayOutputStream(); - PrintStream out = new PrintStream(buf); - try (CLI cli = new CLI(j.getURL())) { - cli.execute(Arrays.asList("create-job", "testJob"), - new ByteArrayInputStream(("" - + "").getBytes()), - out, out); - out.flush(); - assertNotNull("job should be created: " + buf, j.jenkins.getItem("testJob")); - assertEquals("onCreated event should be triggered: " + buf, "C", events.toString()); - } + CLICommandInvoker.Result result = new CLICommandInvoker(j, "create-job"). + withStdin(new ByteArrayInputStream(("").getBytes())). + invokeWithArgs("testJob"); + assertThat(result, CLICommandInvoker.Matcher.succeeded()); + assertNotNull("job should be created: " + result, j.jenkins.getItem("testJob")); + assertEquals("onCreated event should be triggered: " + result, "C", events.toString()); } } diff --git a/test/src/test/java/hudson/security/CliAuthenticationTest.java b/test/src/test/java/hudson/security/CliAuthenticationTest.java index 52b17fc9ebfe3bb4bf1906ff276a4b0c9ffe3acc..bdddf14407b1058805b1ed8b0b86832846ebf424 100644 --- a/test/src/test/java/hudson/security/CliAuthenticationTest.java +++ b/test/src/test/java/hudson/security/CliAuthenticationTest.java @@ -7,9 +7,6 @@ import static org.junit.Assert.assertTrue; import hudson.cli.CLI; import hudson.cli.CLICommand; -import hudson.cli.ClientAuthenticationCache; -import hudson.cli.LoginCommand; -import hudson.cli.LogoutCommand; import jenkins.model.Jenkins; import org.acegisecurity.Authentication; import org.apache.commons.io.input.NullInputStream; @@ -25,6 +22,7 @@ import java.util.Arrays; /** * @author Kohsuke Kawaguchi */ +@SuppressWarnings("deprecation") // Remoting-based CLI usages intentional public class CliAuthenticationTest { @Rule @@ -88,7 +86,7 @@ public class CliAuthenticationTest { } @Test - @For({LoginCommand.class, LogoutCommand.class, ClientAuthenticationCache.class}) + @For({hudson.cli.LoginCommand.class, hudson.cli.LogoutCommand.class, hudson.cli.ClientAuthenticationCache.class}) public void login() throws Exception { j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); diff --git a/test/src/test/java/jenkins/CLITest.java b/test/src/test/java/jenkins/CLITest.java index 054d4df5a113d0e00ee9dbcbc08287aee6318d39..43c8be4cf082cdde0dc5505106f04707b83f0324 100644 --- a/test/src/test/java/jenkins/CLITest.java +++ b/test/src/test/java/jenkins/CLITest.java @@ -1,21 +1,11 @@ package jenkins; -import hudson.cli.FullDuplexHttpStream; -import hudson.model.Computer; import hudson.model.Failure; -import hudson.remoting.Channel; +import static org.junit.Assert.*; import org.junit.Rule; import org.junit.Test; import org.jvnet.hudson.test.JenkinsRule; -import java.io.FileNotFoundException; -import java.net.URL; - -import static org.junit.Assert.*; - -/** - * @author Kohsuke Kawaguchi - */ public class CLITest { @Rule public JenkinsRule j = new JenkinsRule(); @@ -26,39 +16,35 @@ public class CLITest { @Test public void killSwitch() throws Exception { // this should succeed, as a control case - makeHttpCall(); - makeJnlpCall(); + j.jenkins.setSlaveAgentPort(-1); // force HTTP connection + makeCall(); + j.jenkins.setSlaveAgentPort(0); // allow TCP connection + makeCall(); - CLI.DISABLED = true; + CLI.get().setEnabled(false); try { - try { - makeHttpCall(); - fail("Should have been rejected"); - } catch (FileNotFoundException e) { - // attempt to make a call should fail - } - try { - makeJnlpCall(); - fail("Should have been rejected"); - } catch (Exception e) { - // attempt to make a call should fail - e.printStackTrace(); - - // the current expected failure mode is EOFException, though we don't really care how it fails - } - } finally { - CLI.DISABLED = false; + j.jenkins.setSlaveAgentPort(-1); + makeCall(); + fail("Should have been rejected"); + } catch (Exception e) { + // attempt to make a call should fail + e.printStackTrace(); + // currently sends a 403 + } + try { + j.jenkins.setSlaveAgentPort(0); + makeCall(); + fail("Should have been rejected"); + } catch (Exception e) { + // attempt to make a call should fail + e.printStackTrace(); + + // the current expected failure mode is EOFException, though we don't really care how it fails } } - private void makeHttpCall() throws Exception { - FullDuplexHttpStream con = new FullDuplexHttpStream(new URL(j.getURL(), "cli")); - Channel ch = new Channel("test connection", Computer.threadPoolForRemoting, con.getInputStream(), con.getOutputStream()); - ch.close(); - } - - private void makeJnlpCall() throws Exception { - int r = hudson.cli.CLI._main(new String[]{"-s",j.getURL().toString(), "version"}); + private void makeCall() throws Exception { + int r = hudson.cli.CLI._main(new String[] {"-s", j.getURL().toString(), "-remoting", "-noKeyAuth", "version"}); if (r!=0) throw new Failure("CLI failed"); } diff --git a/test/src/test/java/jenkins/security/Security218BlackBoxTest.java b/test/src/test/java/jenkins/security/Security218BlackBoxTest.java index 4776ee92e42bf627a844c5b5567352fe7e0137a3..b420978b11511bc21ad06da770f5a27129998921 100644 --- a/test/src/test/java/jenkins/security/Security218BlackBoxTest.java +++ b/test/src/test/java/jenkins/security/Security218BlackBoxTest.java @@ -85,7 +85,7 @@ public class Security218BlackBoxTest { @Rule public JenkinsRule r = new JenkinsRule(); - @SuppressWarnings("deprecation") // really mean to use getPage(String) + @SuppressWarnings("deprecation") // really mean to use getPage(String), and Remoting-based CLI @PresetData(PresetData.DataSet.ANONYMOUS_READONLY) // TODO userContent inaccessible without authentication otherwise @Test public void probe() throws Exception { @@ -277,7 +277,7 @@ public class Security218BlackBoxTest { try { CLI cli = new CLI(r.getURL()) { @Override - protected CliPort getCliTcpPort(String url) throws IOException { + protected CliPort getCliTcpPort(URL url) throws IOException { return new CliPort(new InetSocketAddress(proxySocket.getInetAddress(), proxySocket.getLocalPort()), /* ignore identity */ null, 1); } }; diff --git a/test/src/test/java/jenkins/security/Security218CliTest.java b/test/src/test/java/jenkins/security/Security218CliTest.java index f9f39c4d46d3388a1039d6275c6d0d18f11161a0..cfa0002d7114389c8887a94c8218ff557d0bfc06 100644 --- a/test/src/test/java/jenkins/security/Security218CliTest.java +++ b/test/src/test/java/jenkins/security/Security218CliTest.java @@ -41,6 +41,7 @@ import org.jvnet.hudson.test.TestExtension; import org.jvnet.hudson.test.recipes.PresetData; import org.kohsuke.args4j.Argument; +@SuppressWarnings("deprecation") // Remoting-based CLI usages intentional public class Security218CliTest { @Rule @@ -197,8 +198,8 @@ public class Security218CliTest { @Argument(metaVar = "command", usage = "Command to be launched by the payload", required = true, index = 1) public String command; - + @Override protected int run() throws Exception { Payload payloadItem = Payload.valueOf(this.payload); PayloadCaller callable = new PayloadCaller(payloadItem, command); diff --git a/test/src/test/java/jenkins/security/Security232Test.java b/test/src/test/java/jenkins/security/Security232Test.java index f5a33ab6ab6ff1f94e7024f1ccb912f771264559..65e0ea05d48d4974a6109b9f1930f30cc04cbc7e 100644 --- a/test/src/test/java/jenkins/security/Security232Test.java +++ b/test/src/test/java/jenkins/security/Security232Test.java @@ -28,10 +28,10 @@ import java.rmi.activation.ActivationInstantiator; import java.rmi.server.ObjID; import java.rmi.server.RemoteObject; import java.rmi.server.UnicastRemoteObject; +import java.util.Collections; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import javax.net.SocketFactory; -import static jenkins.security.security218.Payload.CommonsCollections1; import jenkins.security.security218.ysoserial.payloads.CommonsCollections1; import jenkins.security.security218.ysoserial.payloads.ObjectPayload; import static org.junit.Assert.*; @@ -59,6 +59,8 @@ public class Security232Test { @Test public void commonsCollections1() throws Exception { + r.jenkins.setAgentProtocols(Collections.singleton("CLI-connect")); // override CliProtocol.OPT_IN + File pwned = new File(r.jenkins.getRootDir(), "pwned"); int jrmpPort = 12345; diff --git a/test/src/test/resources/hudson/cli/id_rsa b/test/src/test/resources/hudson/cli/id_rsa new file mode 100644 index 0000000000000000000000000000000000000000..ee2ad6b569a3b5ebbea0d5d1bfcf6ed2bd0dbe27 --- /dev/null +++ b/test/src/test/resources/hudson/cli/id_rsa @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAyTqwFqp5Ww2Tr/52D7hhdOwgzYGBUqxrOFopa+kjNEL1Yqwb ++mApUWZ+D3zN9PurhUcVUfeYVXiYWFJ0kG72HIJawL/0BR5oYxRJfumK8Z/sAzAL +xdhc5O5twETrr9gU3cxtvF5oJNP0I9HickAOeC+ZNpiDIIblrhvxXl/QwqrR+/Gv +Nb8TApj+rxXEfNp+N69iGnnxzWn1FeKeOAWpwoBAxZNoqBQAFacF7xfQnoygyekC +xk+ts2O5Zzv8iJ10sVf+x2Q79rxAtsc0xOGhZbBAzbmFTz0PE4iWuo/Vo1c6mM7u +/dam+FxB2NqPNw7W+4eiCnEVkiQZlrxmuGvK7wIDAQABAoIBACml1+QZDFzoBnUa +eVzvkFwesvtVnmp5/QcAwinvarXaVedCL9g2JtcOG3EhJ49YtzsyZxs7329xMja1 +eiKalJ157UaPc/XLQVegT0XRGEzCCJrwSr979F39awGsQgt28XqmYN/nui5FH/Z5 +7iAvWc9OKqu+DQWiZc8PQXmC4zYmvhGQ8vKx44RSqlWCjd9IqBVhpE5gxpI/SmCx +umUNNtoH0hBWr+MsVHzr6UUrC3a99+7bB4We8XMXXFLzbTUSgiYFmK+NxPs/Fux/ +IAyXAMbDw2HeqZ7g4kTaf4cvmVOwhh4zlvB4p7j301LdO1jmvs9z0fn/QJcTpVM7 +ISMKwAECgYEA/uKVdmOKTk3dKzKRFXtWJjqypOXakoX+25lUcVv2PXYRr8Sln9jC +A13fbhvwq+FqbdnNlB23ag5niCVLfUpB1DYYP5jd4lU8D6HZQiHlmokB6nLT9NIW +iTcG88E58Bta/l1Ue5Yn+LqluBC4i289wFbH1kZyxQ565s5dJEv9uAECgYEAyhwF +ZOqTK2lZe5uuN4owVLQaYFj9fsdFHULzlK/UAtkG1gCJhjBmwSEpZFFMH6WgwHk5 +SHJEom0uB4qRv8gQcxl9OSiDsp56ymr0NBhlPVXWr6IzLotLy5XBC1muqvYYlj7E +kHgSet/h8RUM/FeEiwOFHDU2DkMb8Qx1hfMdAu8CgYBSEsYL9CuB4WK5WTQMlcV8 +0+PYY0dJbSpOrgXZ5sHYsp8pWQn3+cUnbl/WxdpujkxGCR9AdX0tAmxmE5RGSNX/ +rleKiv/PtKB9bCFYQS/83ecnBkioCcpF7tknPm4YmcZoJ8dfcE94sSlRpti11WEu +AQOiRNcKCwqaLZMib/HIAQKBgQCdiOffeERMYypfgcJzAiCX9WZV0SeOCS7jFwub +ys17hsSgS/zl/pYpVXrY+dFXHZfGTvcKdB7xaB6nvCfND9lajfSgd+bndEYLvwAo +Fxfajizv64LvdZ4XytuUyEuwcHBLtBMs9Jqa8iU/8AOWMXVbkdvQV92RkleWNPrp +9MyZOwKBgQD9x8MnX5LVBfQKuL9qX6l9Da06EyMkzfz3obKn9AAJ3Xj9+45TNPJu +HnZyvJWesl1vDjXQTm+PVkdyE0WQgoiVX+wxno0hsoly5Uqb5EYHtTUrZzRpkyLK +1VmtDxT5D8gorUgn6crzk4PKaxRkPfAimZdlkQm6iOtuR3kqn5BtIQ== +-----END RSA PRIVATE KEY----- diff --git a/test/src/test/resources/hudson/cli/id_rsa.pub b/test/src/test/resources/hudson/cli/id_rsa.pub new file mode 100644 index 0000000000000000000000000000000000000000..91f8ff7180e5f05f989b9aec2104d102372e88a4 --- /dev/null +++ b/test/src/test/resources/hudson/cli/id_rsa.pub @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDJOrAWqnlbDZOv/nYPuGF07CDNgYFSrGs4Wilr6SM0QvVirBv6YClRZn4PfM30+6uFRxVR95hVeJhYUnSQbvYcglrAv/QFHmhjFEl+6Yrxn+wDMAvF2Fzk7m3AROuv2BTdzG28Xmgk0/Qj0eJyQA54L5k2mIMghuWuG/FeX9DCqtH78a81vxMCmP6vFcR82n43r2IaefHNafUV4p44BanCgEDFk2ioFAAVpwXvF9CejKDJ6QLGT62zY7lnO/yInXSxV/7HZDv2vEC2xzTE4aFlsEDNuYVPPQ8TiJa6j9WjVzqYzu791qb4XEHY2o83Dtb7h6IKcRWSJBmWvGa4a8rv your_email@example.com diff --git a/war/pom.xml b/war/pom.xml index 765bbc9b8a7d97208251bd6741930d16cf367b61..24c79dd05025ce2e15868b98be48cc9972278400 100644 --- a/war/pom.xml +++ b/war/pom.xml @@ -134,7 +134,7 @@ THE SOFTWARE. org.jenkins-ci.modules sshd - 1.10 + 1.11 org.jenkins-ci.ui diff --git a/war/src/main/webapp/help/parameter/file.html b/war/src/main/webapp/help/parameter/file.html index 54866f662c521c84bf84157f5c0a5d386abfaed9..22b0a928c76701011fdb802e808b27e9b1305266 100644 --- a/war/src/main/webapp/help/parameter/file.html +++ b/war/src/main/webapp/help/parameter/file.html @@ -21,4 +21,10 @@ anything (but it also will not delete anything that's already in the workspace.)

+

+ From the CLI, the -p option to the build command + can take either a local filename (-remoting mode only), + or an empty value to read from standard input. + (In the latter mode, only one file parameter can be defined.) +

\ No newline at end of file