-
-
Notifications
You must be signed in to change notification settings - Fork 8.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[JENKINS-41745] Non-Remoting-based CLI #2795
Changes from 3 commits
492dbbe
2fe2487
8302b85
7da0d39
1e860c8
624ba59
4df2bf1
c2a5d85
8d622e0
12ae48e
b8ad360
82cd1bd
5f0bb2a
9ffb5d8
2cf0ac5
9e427ce
f437ccc
9434d0e
71db95a
996c52e
59d5013
473744c
289db88
f3dae19
7174e3e
7baf81d
e69ba0f
60632c0
466d74d
f168936
551808c
f6314b7
9812db7
71d7562
56cee95
c74999f
4c1910a
4069e67
03aa2a1
fb311c6
caf3e8e
7f633de
f7006b4
3b92399
94d5f96
dbe24a8
5caee58
9fc59bf
7bca0a1
3939820
da58159
724ebbc
49f0360
c41c97b
6c63890
9150d5b
6ffc57a
2e7fe04
f3da0e4
0f87e10
98f227c
45092f0
27d508d
7ae404c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -32,6 +32,7 @@ | |
import hudson.remoting.RemoteOutputStream; | ||
import hudson.remoting.SocketChannelStream; | ||
import hudson.remoting.SocketOutputStream; | ||
import hudson.util.QuotedStringTokenizer; | ||
|
||
import javax.crypto.SecretKey; | ||
import javax.crypto.spec.SecretKeySpec; | ||
|
@@ -57,8 +58,11 @@ | |
import java.net.HttpURLConnection; | ||
import java.net.InetSocketAddress; | ||
import java.net.Socket; | ||
import java.net.SocketAddress; | ||
import java.net.SocketTimeoutException; | ||
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; | ||
|
@@ -70,11 +74,24 @@ | |
import java.util.List; | ||
import java.util.Locale; | ||
import java.util.Properties; | ||
import java.util.Set; | ||
import java.util.concurrent.ExecutorService; | ||
import java.util.concurrent.Executors; | ||
import java.util.logging.ConsoleHandler; | ||
import java.util.logging.Level; | ||
import java.util.logging.Logger; | ||
import static java.util.logging.Level.*; | ||
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.io.NoCloseInputStream; | ||
import org.apache.sshd.common.util.io.NoCloseOutputStream; | ||
|
||
/** | ||
* CLI entry point to Jenkins. | ||
|
@@ -90,6 +107,7 @@ public class CLI implements AutoCloseable { | |
private final String httpsProxyTunnel; | ||
private final String authorization; | ||
|
||
/** Connection via {@link Mode#REMOTING}, for tests only. */ | ||
public CLI(URL jenkins) throws IOException, InterruptedException { | ||
this(jenkins,null); | ||
} | ||
|
@@ -111,7 +129,8 @@ public CLI(URL jenkins, ExecutorService exec) throws IOException, InterruptedExc | |
public CLI(URL jenkins, ExecutorService exec, String httpsProxyTunnel) throws IOException, InterruptedException { | ||
this(new CLIConnectionFactory().url(jenkins).executorService(exec).httpsProxyTunnel(httpsProxyTunnel)); | ||
} | ||
|
||
|
||
/** Connection via {@link Mode#REMOTING}. */ | ||
/*package*/ CLI(CLIConnectionFactory factory) throws IOException, InterruptedException { | ||
URL jenkins = factory.jenkins; | ||
this.httpsProxyTunnel = factory.httpsProxyTunnel; | ||
|
@@ -128,7 +147,8 @@ public CLI(URL jenkins, ExecutorService exec, String httpsProxyTunnel) throws IO | |
try { | ||
_channel = connectViaCliPort(jenkins, getCliTcpPort(url)); | ||
} catch (IOException e) { | ||
LOGGER.log(Level.FINE,"Failed to connect via CLI port. Falling back to HTTP",e); | ||
System.err.println("Failed to connect via CLI port. Falling back to HTTP: " + e.getMessage()); | ||
LOGGER.log(Level.FINE, null, e); | ||
try { | ||
_channel = connectViaHttp(url); | ||
} catch (IOException e2) { | ||
|
@@ -146,9 +166,8 @@ public CLI(URL jenkins, ExecutorService exec, String httpsProxyTunnel) throws IO | |
} | ||
|
||
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); | ||
LOGGER.log(FINE, "Trying to connect to {0} via Remoting over HTTP", url); | ||
URL jenkins = new URL(url + "cli?remoting=true"); | ||
|
||
FullDuplexHttpStream con = new FullDuplexHttpStream(jenkins,authorization); | ||
Channel ch = new Channel("Chunked connection to "+jenkins, | ||
|
@@ -165,7 +184,7 @@ protected void onDead() { | |
} | ||
|
||
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); | ||
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 | ||
|
@@ -375,12 +394,6 @@ public void upgrade() { | |
} | ||
|
||
public static void main(final String[] _args) throws Exception { | ||
// Logger l = Logger.getLogger(Channel.class.getName()); | ||
// l.setLevel(ALL); | ||
// ConsoleHandler h = new ConsoleHandler(); | ||
// h.setLevel(ALL); | ||
// l.addHandler(h); | ||
// | ||
try { | ||
System.exit(_main(_args)); | ||
} catch (Throwable t) { | ||
|
@@ -390,6 +403,7 @@ public static void main(final String[] _args) throws Exception { | |
} | ||
} | ||
|
||
private enum Mode {HTTP, SSH, REMOTING} | ||
public static int _main(String[] _args) throws Exception { | ||
List<String> args = Arrays.asList(_args); | ||
PrivateKeyProvider provider = new PrivateKeyProvider(); | ||
|
@@ -403,12 +417,40 @@ public static int _main(String[] _args) throws Exception { | |
|
||
boolean tryLoadPKey = true; | ||
|
||
Mode mode = null; | ||
|
||
String user = null; | ||
|
||
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()); | ||
} | ||
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()); | ||
} | ||
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()); | ||
} | ||
if(head.equals("-s") && args.size()>=2) { | ||
url = args.get(1); | ||
args = args.subList(2,args.size()); | ||
|
@@ -446,11 +488,27 @@ public boolean verify(String s, SSLSession sslSession) { | |
sshAuthRequestedExplicitly = true; | ||
continue; | ||
} | ||
if (head.equals("-user") && args.size() >= 2) { | ||
user = 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("-logger") && args.size() >= 2) { | ||
Level level = parse(args.get(1)); | ||
ConsoleHandler h = new ConsoleHandler(); | ||
h.setLevel(level); | ||
for (Logger logger : new Logger[] {LOGGER, PlainCLIProtocol.LOGGER}) { // perhaps also Channel | ||
logger.setLevel(level); | ||
logger.addHandler(h); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You seem to assume at other places that at least There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually it does, though only for messages at @ydubreuil’s trick from #2711 solves the conflict, so I will use that. As a bonus, it also makes |
||
} | ||
args = args.subList(2, args.size()); | ||
continue; | ||
} | ||
break; | ||
} | ||
|
||
|
@@ -465,12 +523,35 @@ public boolean verify(String s, SSLSession sslSession) { | |
if (tryLoadPKey && !provider.hasKeys()) | ||
provider.readFromDefaultLocations(); | ||
|
||
if (mode == null) { | ||
mode = Mode.HTTP; | ||
} | ||
|
||
LOGGER.log(FINE, "using connection mode {0}", mode); | ||
|
||
if (mode == Mode.SSH) { | ||
if (user == null) { | ||
// TODO SshCliAuthenticator already autodetects the user based on public key; why cannot AsynchronousCommand.getCurrentUser do the same? | ||
System.err.println("-user required when using -ssh"); | ||
return -1; | ||
} | ||
return sshConnection(url, user, args, provider); | ||
} | ||
|
||
if (user != null) { | ||
System.err.println("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); | ||
} | ||
|
||
if (mode == Mode.HTTP) { | ||
return plainHttpConnection(url, args, factory); | ||
} | ||
|
||
CLI cli = factory.connect(); | ||
try { | ||
if (provider.hasKeys()) { | ||
|
@@ -507,6 +588,130 @@ public boolean verify(String s, SSLSession sslSession) { | |
} | ||
} | ||
|
||
private static int sshConnection(String jenkinsUrl, String user, List<String> args, PrivateKeyProvider provider) throws IOException { | ||
URL url = new URL(jenkinsUrl + "/login"); | ||
URLConnection conn = url.openConnection(); | ||
String endpointDescription = conn.getHeaderField("X-SSH-Endpoint"); | ||
|
||
if (endpointDescription == null) { | ||
System.err.println("No header 'X-SSH-Endpoint' returned by Jenkins"); | ||
return -1; | ||
} | ||
|
||
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) { | ||
/** unknown key is okay, but log */ | ||
LOGGER.log(Level.WARNING, "Unknown host key for {0}", remoteAddress.toString()); | ||
// TODO should not trust unknown hosts by default; this should be opt-in | ||
return true; | ||
} | ||
}, 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()) { | ||
System.err.println("Offering " + pair.getPrivate().getAlgorithm() + " private key"); | ||
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 static int plainHttpConnection(String url, List<String> args, CLIConnectionFactory factory) throws IOException, InterruptedException { | ||
LOGGER.log(FINE, "Trying to connect to {0} via plain protocol over HTTP", url); | ||
URL jenkins = new URL(url + "cli?remoting=false"); | ||
FullDuplexHttpStream streams = new FullDuplexHttpStream(jenkins, factory.authorization); | ||
class ClientSideImpl extends PlainCLIProtocol.ClientSide { | ||
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"); | ||
} | ||
} | ||
@Override | ||
protected synchronized void onExit(int code) { | ||
this.exit = code; | ||
notify(); | ||
} | ||
@Override | ||
protected void onStdout(byte[] chunk) throws IOException { | ||
System.out.write(chunk); | ||
} | ||
@Override | ||
protected void onStderr(byte[] chunk) throws IOException { | ||
System.err.write(chunk); | ||
} | ||
} | ||
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) { | ||
stdin.write(c); | ||
} | ||
connection.sendEndStdin(); | ||
} catch (IOException x) { | ||
x.printStackTrace(); | ||
} | ||
} | ||
}.start(); | ||
synchronized (connection) { | ||
connection.wait(); | ||
} | ||
return connection.exit; | ||
} | ||
|
||
private static String computeVersion() { | ||
Properties props = new Properties(); | ||
try { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's already in the core due to the sshd-module
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, it is in
war
.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, nvm