3daad75bbcdc1c0804b8319149e6becc56951b20
[teamcity/git-plugin.git] / git-agent / src / jetbrains / buildServer / buildTriggers / vcs / git / agent / JSchClient.java
1 /*
2  * Copyright 2000-2018 JetBrains s.r.o.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16
17 package jetbrains.buildServer.buildTriggers.vcs.git.agent;
18
19 import com.jcraft.jsch.*;
20 import jetbrains.buildServer.buildTriggers.vcs.git.GitUtils;
21 import org.jetbrains.annotations.NotNull;
22 import org.jetbrains.annotations.Nullable;
23 import org.jetbrains.git4idea.ssh.GitSSHHandler;
24
25 import javax.security.auth.callback.Callback;
26 import javax.security.auth.callback.CallbackHandler;
27 import javax.security.auth.callback.UnsupportedCallbackException;
28 import java.io.File;
29 import java.io.IOException;
30 import java.io.InputStream;
31 import java.security.Security;
32 import java.text.SimpleDateFormat;
33 import java.util.*;
34 import java.util.concurrent.TimeUnit;
35 import java.util.concurrent.atomic.AtomicLong;
36
37 public class JSchClient {
38
39   private final static int BUF_SIZE = 32 * 1024;
40
41   private final String myHost;
42   private final String myUsername;
43   private final Integer myPort;
44   private final String myCommand;
45   private final Logger myLogger;
46
47   private JSchClient(@NotNull String host,
48                      @Nullable String username,
49                      @Nullable Integer port,
50                      @NotNull String command,
51                      @NotNull Logger logger) {
52     myHost = host;
53     myUsername = username;
54     myPort = port;
55     myCommand = command;
56     myLogger = logger;
57   }
58
59
60   public static void main(String... args) {
61     boolean debug = Boolean.parseBoolean(System.getenv(GitSSHHandler.TEAMCITY_DEBUG_SSH));
62     Logger logger = debug ? new StdErrLogger() : new InMemoryLogger(Logger.INFO);
63     try {
64       JSchClient ssh = createClient(logger, args);
65       ssh.run();
66     } catch (Throwable t) {
67       if (logger instanceof InMemoryLogger) {
68         ((InMemoryLogger)logger).printLog();
69       }
70       System.err.println(t.getMessage());
71       if (t instanceof NullPointerException || debug)
72         t.printStackTrace();
73       System.exit(1);
74     }
75   }
76
77
78   private static JSchClient createClient(@NotNull Logger logger, String[] args) {
79     // Git runs ssh as follows (https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables):
80     //
81     //   $GIT_SSH [-p <port>] [username@]host <command>
82     //
83     // e.g.
84     //
85     //   $GIT_SSH 'git@server.com' 'git-upload-pack '\''user/repo.git'\'''
86     //
87     // The git-upload-pack command and its args are passed as a single argument to $GIT_SSH.
88     //
89     // Git LFS also uses $GIT_SSH, but it doesn't combine all arguments into a single $GIT_SSH arg:
90     //
91     //     $GIT_SSH 'git@server.com' 'git-lfs-authenticate' 'user/repo.git' 'download'
92     //
93     // we need to combine them ourselves.
94
95     int i = 0;
96     Integer port = null;
97     //noinspection HardCodedStringLiteral
98     if ("-p".equals(args[i])) {
99       i++;
100       port = Integer.parseInt(args[i++]);
101     }
102     String host = args[i++];
103     String user;
104     int atIndex = host.lastIndexOf('@');
105     if (atIndex == -1) {
106       user = null;
107     }
108     else {
109       user = host.substring(0, atIndex);
110       host = host.substring(atIndex + 1);
111     }
112
113     String command = args[i];
114     if (i < args.length - 1) { // contains additional arguments for the command
115       StringBuilder commandWithArguments = new StringBuilder(command);
116       for (int j = i + 1; j < args.length; j++) {
117         commandWithArguments.append(" ").append(args[j]);
118       }
119       command = commandWithArguments.toString();
120     }
121     return new JSchClient(host, user, port, command, logger);
122   }
123
124
125   public void run() throws Exception {
126     myLogger.log(Logger.INFO, "SSH command to run: " + myCommand);
127     ChannelExec channel = null;
128     Session session = null;
129     try {
130       JSch.setLogger(myLogger);
131       JSch jsch = new JSch();
132       String privateKeyPath = System.getenv(GitSSHHandler.TEAMCITY_PRIVATE_KEY_PATH);
133       if (privateKeyPath != null) {
134         jsch.addIdentity(privateKeyPath, System.getenv(GitSSHHandler.TEAMCITY_PASSPHRASE));
135       } else {
136         String userHome = System.getProperty("user.home");
137         if (userHome != null) {
138           File homeDir = new File(userHome);
139           File ssh = new File(homeDir, ".ssh");
140           File rsa = new File(ssh, "id_rsa");
141           if (rsa.isFile()) {
142             jsch.addIdentity(rsa.getAbsolutePath());
143           }
144           File dsa = new File(ssh, "id_dsa");
145           if (dsa.isFile()) {
146             jsch.addIdentity(dsa.getAbsolutePath());
147           }
148           File config = new File(ssh, "config");
149           if (config.isFile()) {
150             ConfigRepository configRepository = OpenSSHConfig.parseFile(config.getAbsolutePath());
151             jsch.setConfigRepository(new TeamCityConfigRepository(configRepository, myUsername));
152           }
153         }
154       }
155       session = jsch.getSession(myUsername, myHost, myPort != null ? myPort : 22);
156
157       String teamCityVersion = System.getenv(GitSSHHandler.TEAMCITY_VERSION);
158       if (teamCityVersion != null) {
159         session.setClientVersion(GitUtils.getSshClientVersion(session.getClientVersion(), teamCityVersion));
160       }
161
162       if (Boolean.parseBoolean(System.getenv(GitSSHHandler.SSH_IGNORE_KNOWN_HOSTS_ENV))) {
163         session.setConfig("StrictHostKeyChecking", "no");
164       } else {
165         String userHome = System.getProperty("user.home");
166         if (userHome != null) {
167           File homeDir = new File(userHome);
168           File ssh = new File(homeDir, ".ssh");
169           File knownHosts = new File(ssh, "known_hosts");
170           if (knownHosts.isFile()) {
171             try {
172               jsch.setKnownHosts(knownHosts.getAbsolutePath());
173             } catch (Exception e) {
174               myLogger.log(Logger.WARN, "Failed to configure known hosts: '" + e.toString() + "'");
175             }
176           }
177         }
178       }
179
180       String authMethods = System.getenv(GitSSHHandler.TEAMCITY_SSH_PREFERRED_AUTH_METHODS);
181       if (authMethods != null && authMethods.length() > 0)
182         session.setConfig("PreferredAuthentications", authMethods);
183
184       EmptySecurityCallbackHandler.install();
185
186       // It looks like sometimes session/channel close() doesn't interrupt
187       // all reads. Ask jsch to create daemon threads so that uninterrupted
188       // threads don't prevent us from exit.
189       session.setDaemonThread(true);
190
191       session.connect();
192
193       channel = (ChannelExec) session.openChannel("exec");
194       channel.setPty(false);
195       channel.setCommand(myCommand);
196       channel.setInputStream(System.in);
197       channel.setErrStream(System.err);
198       InputStream input = channel.getInputStream();
199       Integer timeoutSeconds = getTimeoutSeconds();
200       if (timeoutSeconds != null) {
201         channel.connect(timeoutSeconds * 1000);
202       } else {
203         channel.connect();
204       }
205
206
207       if (!channel.isConnected()) {
208         throw new IOException("Connection failed");
209       }
210
211       Copy copyThread = new Copy(input);
212       if (timeoutSeconds != null) {
213         new Timer(copyThread, timeoutSeconds).start();
214       }
215       copyThread.start();
216       copyThread.join();
217       copyThread.rethrowError();
218     } finally {
219       if (channel != null)
220         channel.disconnect();
221       if (session != null)
222         session.disconnect();
223     }
224   }
225
226
227   @Nullable
228   private Integer getTimeoutSeconds() {
229     String timeout = System.getenv(GitSSHHandler.TEAMCITY_SSH_IDLE_TIMEOUT_SECONDS);
230     if (timeout == null)
231       return null;
232     try {
233       return Integer.parseInt(timeout);
234     } catch (NumberFormatException e) {
235       myLogger.log(Logger.WARN, "Failed to parse idle timeout: '" + timeout + "'");
236       return null;
237     }
238   }
239
240
241   private class Timer extends Thread {
242     private final long myThresholdNanos;
243     private volatile Copy myCopyThread;
244     Timer(@NotNull Copy copyThread, long timeoutSeconds) {
245       myCopyThread = copyThread;
246       myThresholdNanos = TimeUnit.SECONDS.toNanos(timeoutSeconds);
247       setDaemon(true);
248       setName("Timer");
249     }
250
251     @Override
252     public void run() {
253       boolean logged = false;
254       long sleepInterval = Math.min(TimeUnit.SECONDS.toMillis(10), TimeUnit.NANOSECONDS.toMillis(myThresholdNanos));
255       //noinspection InfiniteLoopStatement: it is a daemon thread and doesn't prevent process from termination
256       while (true) {
257         if (System.nanoTime() - myCopyThread.getTimestamp() > myThresholdNanos) {
258           if (!logged) {
259             myLogger.log(Logger.ERROR, String.format("Timeout error: no activity for %s seconds", TimeUnit.NANOSECONDS.toSeconds(myThresholdNanos)));
260             logged = true;
261           }
262           myCopyThread.interrupt();
263         } else {
264           try {
265             Thread.sleep(sleepInterval);
266           } catch (Exception e) {
267             //ignore
268           }
269         }
270       }
271     }
272   }
273
274
275   private class Copy extends Thread {
276     private final InputStream myInput;
277     private final AtomicLong myTimestamp = new AtomicLong(System.nanoTime());
278     private volatile Exception myError;
279     Copy(@NotNull InputStream input) {
280       myInput = input;
281       setName("Copy");
282     }
283
284     @Override
285     public void run() {
286       byte[] buffer = new byte[BUF_SIZE];
287       int count;
288       try {
289         while ((count = myInput.read(buffer)) != -1) {
290           System.out.write(buffer, 0, count);
291           myTimestamp.set(System.nanoTime());
292           if (System.out.checkError()) {
293             myLogger.log(Logger.ERROR, "Error while writing to stdout");
294             throw new IOException("Error while writing to stdout");
295           }
296         }
297       } catch (Exception e) {
298         myError = e;
299       }
300     }
301
302     long getTimestamp() {
303       return myTimestamp.get();
304     }
305
306     void rethrowError() throws Exception {
307       if (myError != null)
308         throw myError;
309     }
310   }
311
312
313   private static class StdErrLogger implements Logger {
314     private final SimpleDateFormat myDateFormat = new SimpleDateFormat("[HH:mm:ss.SSS]");
315     @Override
316     public boolean isEnabled(final int level) {
317       return true;
318     }
319
320     @Override
321     public void log(final int level, final String message) {
322       System.err.print(getTimestamp());
323       System.err.print(" ");
324       System.err.print(getLevel(level));
325       System.err.print(" ");
326       System.err.println(message);
327     }
328
329     @NotNull
330     private String getTimestamp() {
331       synchronized (myDateFormat) {
332         return myDateFormat.format(new Date());
333       }
334     }
335   }
336
337
338   private static class InMemoryLogger implements Logger {
339     private final int myMinLogLevel;
340     private final List<LogEntry> myLogEntries;
341     InMemoryLogger(int minLogLevel) {
342       myMinLogLevel = minLogLevel;
343       myLogEntries = new ArrayList<LogEntry>();
344     }
345
346     @Override
347     public boolean isEnabled(final int level) {
348       return level >= myMinLogLevel;
349     }
350
351     @Override
352     public void log(final int level, final String message) {
353       if (isEnabled(level)) {
354         synchronized (myLogEntries) {
355           myLogEntries.add(new LogEntry(System.currentTimeMillis(), level, message));
356         }
357       }
358     }
359
360     void printLog() {
361       SimpleDateFormat dateFormat = new SimpleDateFormat("[HH:mm:ss.SSS]");
362       synchronized (myLogEntries) {
363         for (LogEntry entry : myLogEntries) {
364           System.err.print(dateFormat.format(new Date(entry.myTimestamp)));
365           System.err.print(" ");
366           System.err.print(getLevel(entry.myLogLevel));
367           System.err.print(" ");
368           System.err.println(entry.myMessage);
369         }
370       }
371     }
372
373     private static class LogEntry {
374       private final long myTimestamp;
375       private final int myLogLevel;
376       private final String myMessage;
377       LogEntry(long timestamp, int logLevel, @NotNull String message) {
378         myTimestamp = timestamp;
379         myLogLevel = logLevel;
380         myMessage = message;
381       }
382     }
383   }
384
385
386   @NotNull
387   private static String getLevel(int level) {
388     switch (level) {
389       case Logger.DEBUG:
390         return "DEBUG";
391       case Logger.INFO:
392         return "INFO";
393       case Logger.WARN:
394         return "WARN";
395       case Logger.ERROR:
396         return "ERROR";
397       case Logger.FATAL:
398         return "FATAL";
399       default:
400         return "UNKNOWN";
401     }
402   }
403
404
405   // Doesn't provide any credentials, used instead the default handler from jdk
406   // which reads credentials them from stdin.
407   public static class EmptySecurityCallbackHandler implements CallbackHandler {
408     @Override
409     public void handle(final Callback[] callbacks) throws UnsupportedCallbackException {
410       if (callbacks.length > 0) {
411         throw new UnsupportedCallbackException(callbacks[0], "Unsupported callback");
412       }
413     }
414
415     static void install() {
416       Security.setProperty("auth.login.defaultCallbackHandler", EmptySecurityCallbackHandler.class.getName());
417     }
418   }
419
420
421   // Need to wrap jsch config to workaround its bugs:
422   // https://bugs.eclipse.org/bugs/show_bug.cgi?id=526778
423   // https://bugs.eclipse.org/bugs/show_bug.cgi?id=526867
424   private static class TeamCityConfigRepository implements ConfigRepository {
425     private final ConfigRepository myDelegate;
426     private final String myUser;
427     TeamCityConfigRepository(@NotNull ConfigRepository delegate, @Nullable String user) {
428       myDelegate = delegate;
429       myUser = user;
430     }
431
432     @Override
433     public Config getConfig(final String host) {
434       Config config = myDelegate.getConfig(host);
435       return config != null ? new TeamCityConfig(config, myUser) : null;
436     }
437   }
438
439   private static class TeamCityConfig implements ConfigRepository.Config {
440     private final ConfigRepository.Config myDelegate;
441     private final String myUser;
442     TeamCityConfig(@NotNull ConfigRepository.Config delegate, @Nullable String user) {
443       myDelegate = delegate;
444       myUser = user;
445     }
446
447     @Override
448     public String getHostname() {
449       return myDelegate.getHostname();
450     }
451
452     @Override
453     public String getUser() {
454       // https://bugs.eclipse.org/bugs/show_bug.cgi?id=526778
455       // enforce our username
456       return myUser != null ? myUser : myDelegate.getUser();
457     }
458
459     @Override
460     public int getPort() {
461       return myDelegate.getPort();
462     }
463
464     @Override
465     public String getValue(final String key) {
466       String result = myDelegate.getValue(key);
467       if (result != null) {
468         if ("ServerAliveInterval".equalsIgnoreCase(key) || "ConnectTimeout".equalsIgnoreCase(key)) {
469           // https://bugs.eclipse.org/bugs/show_bug.cgi?id=526867
470           // these timeouts are in seconds, jsch treats them as milliseconds which causes timeout errors
471           try {
472             result = Long.toString(TimeUnit.SECONDS.toMillis(Integer.parseInt(result)));
473           } catch (NumberFormatException e) {
474             // Ignore
475           }
476         }
477       }
478       return result;
479     }
480
481     @Override
482     public String[] getValues(final String key) {
483       return myDelegate.getValues(key);
484     }
485   }
486 }