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