TW-53489 respect .ssh/config on the agent machine
[teamcity/git-plugin.git] / git-agent / src / jetbrains / buildServer / buildTriggers / vcs / git / agent / JSchClient.java
1 /*
2  * Copyright 2000-2017 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.ArrayList;
34 import java.util.Arrays;
35 import java.util.Date;
36 import java.util.List;
37 import java.util.concurrent.TimeUnit;
38
39 public class JSchClient {
40
41   private final static int BUF_SIZE = 32 * 1024;
42
43   private final String myHost;
44   private final String myUsername;
45   private final Integer myPort;
46   private final String myCommand;
47   private final Logger myLogger;
48
49   private JSchClient(@NotNull String host,
50                      @Nullable String username,
51                      @Nullable Integer port,
52                      @NotNull String command,
53                      @NotNull Logger logger) {
54     myHost = host;
55     myUsername = username;
56     myPort = port;
57     myCommand = command;
58     myLogger = logger;
59   }
60
61
62   public static void main(String... args) {
63     boolean debug = Boolean.parseBoolean(System.getenv(GitSSHHandler.TEAMCITY_DEBUG_SSH));
64     Logger logger = debug ? new StdErrLogger() : new InMemoryLogger(Logger.INFO);
65     try {
66       JSchClient ssh = createClient(logger, args);
67       ssh.run();
68     } catch (Throwable t) {
69       if (logger instanceof InMemoryLogger) {
70         ((InMemoryLogger)logger).printLog();
71       }
72       System.err.println(t.getMessage());
73       if (t instanceof NullPointerException || debug)
74         t.printStackTrace();
75       System.exit(1);
76     }
77   }
78
79
80   private static JSchClient createClient(@NotNull Logger logger, String[] args) {
81     if (args.length != 2 && args.length != 4) {
82       System.err.println("Invalid arguments " + Arrays.asList(args));
83       System.exit(1);
84     }
85
86     int i = 0;
87     Integer port = null;
88     //noinspection HardCodedStringLiteral
89     if ("-p".equals(args[i])) {
90       i++;
91       port = Integer.parseInt(args[i++]);
92     }
93     String host = args[i++];
94     String user;
95     int atIndex = host.lastIndexOf('@');
96     if (atIndex == -1) {
97       user = null;
98     }
99     else {
100       user = host.substring(0, atIndex);
101       host = host.substring(atIndex + 1);
102     }
103     String command = args[i];
104     return new JSchClient(host, user, port, command, logger);
105   }
106
107
108   public void run() throws Exception {
109     ChannelExec channel = null;
110     Session session = null;
111     try {
112       JSch.setLogger(myLogger);
113       JSch jsch = new JSch();
114       String privateKeyPath = System.getenv(GitSSHHandler.TEAMCITY_PRIVATE_KEY_PATH);
115       if (privateKeyPath != null) {
116         jsch.addIdentity(privateKeyPath, System.getenv(GitSSHHandler.TEAMCITY_PASSPHRASE));
117       } else {
118         String userHome = System.getProperty("user.home");
119         if (userHome != null) {
120           File homeDir = new File(userHome);
121           File ssh = new File(homeDir, ".ssh");
122           File rsa = new File(ssh, "id_rsa");
123           if (rsa.isFile()) {
124             jsch.addIdentity(rsa.getAbsolutePath());
125           }
126           File dsa = new File(ssh, "id_dsa");
127           if (dsa.isFile()) {
128             jsch.addIdentity(dsa.getAbsolutePath());
129           }
130           File config = new File(ssh, "config");
131           if (config.isFile()) {
132             ConfigRepository configRepository = OpenSSHConfig.parseFile(config.getAbsolutePath());
133             jsch.setConfigRepository(new TeamCityConfigRepository(configRepository, myUsername));
134           }
135         }
136       }
137       session = jsch.getSession(myUsername, myHost, myPort != null ? myPort : 22);
138
139       String teamCityVersion = System.getenv(GitSSHHandler.TEAMCITY_VERSION);
140       if (teamCityVersion != null) {
141         session.setClientVersion(GitUtils.getSshClientVersion(session.getClientVersion(), teamCityVersion));
142       }
143
144       if (Boolean.parseBoolean(System.getenv(GitSSHHandler.SSH_IGNORE_KNOWN_HOSTS_ENV))) {
145         session.setConfig("StrictHostKeyChecking", "no");
146       } else {
147         String userHome = System.getProperty("user.home");
148         if (userHome != null) {
149           File homeDir = new File(userHome);
150           File ssh = new File(homeDir, ".ssh");
151           File knownHosts = new File(ssh, "known_hosts");
152           if (knownHosts.isFile()) {
153             try {
154               jsch.setKnownHosts(knownHosts.getAbsolutePath());
155             } catch (Exception e) {
156               myLogger.log(Logger.WARN, "Failed to configure known hosts: '" + e.toString() + "'");
157             }
158           }
159         }
160       }
161
162       String authMethods = System.getenv(GitSSHHandler.TEAMCITY_SSH_PREFERRED_AUTH_METHODS);
163       if (authMethods != null && authMethods.length() > 0)
164         session.setConfig("PreferredAuthentications", authMethods);
165
166       EmptySecurityCallbackHandler.install();
167
168       session.connect();
169
170       channel = (ChannelExec) session.openChannel("exec");
171       channel.setPty(false);
172       channel.setCommand(myCommand);
173       channel.setInputStream(System.in);
174       channel.setErrStream(System.err);
175       InputStream input = channel.getInputStream();
176       channel.connect();
177
178       if (!channel.isConnected()) {
179         throw new IOException("Connection failed");
180       }
181
182       byte[] buffer = new byte[BUF_SIZE];
183       int count;
184       while ((count = input.read(buffer)) != -1) {
185         System.out.write(buffer, 0, count);
186       }
187     } finally {
188       if (channel != null)
189         channel.disconnect();
190       if (session != null)
191         session.disconnect();
192     }
193   }
194
195
196   private static class StdErrLogger implements Logger {
197     private final SimpleDateFormat myDateFormat = new SimpleDateFormat("[HH:mm:ss.SSS]");
198     @Override
199     public boolean isEnabled(final int level) {
200       return true;
201     }
202
203     @Override
204     public void log(final int level, final String message) {
205       System.err.print(getTimestamp());
206       System.err.print(" ");
207       System.err.print(getLevel(level));
208       System.err.print(" ");
209       System.err.println(message);
210     }
211
212     @NotNull
213     private String getTimestamp() {
214       synchronized (myDateFormat) {
215         return myDateFormat.format(new Date());
216       }
217     }
218   }
219
220
221   private static class InMemoryLogger implements Logger {
222     private final int myMinLogLevel;
223     private final List<LogEntry> myLogEntries;
224     InMemoryLogger(int minLogLevel) {
225       myMinLogLevel = minLogLevel;
226       myLogEntries = new ArrayList<LogEntry>();
227     }
228
229     @Override
230     public boolean isEnabled(final int level) {
231       return level >= myMinLogLevel;
232     }
233
234     @Override
235     public void log(final int level, final String message) {
236       if (isEnabled(level)) {
237         synchronized (myLogEntries) {
238           myLogEntries.add(new LogEntry(System.currentTimeMillis(), level, message));
239         }
240       }
241     }
242
243     void printLog() {
244       SimpleDateFormat dateFormat = new SimpleDateFormat("[HH:mm:ss.SSS]");
245       synchronized (myLogEntries) {
246         for (LogEntry entry : myLogEntries) {
247           System.err.print(dateFormat.format(new Date(entry.myTimestamp)));
248           System.err.print(" ");
249           System.err.print(getLevel(entry.myLogLevel));
250           System.err.print(" ");
251           System.err.println(entry.myMessage);
252         }
253       }
254     }
255
256     private static class LogEntry {
257       private final long myTimestamp;
258       private final int myLogLevel;
259       private final String myMessage;
260       LogEntry(long timestamp, int logLevel, @NotNull String message) {
261         myTimestamp = timestamp;
262         myLogLevel = logLevel;
263         myMessage = message;
264       }
265     }
266   }
267
268
269   @NotNull
270   private static String getLevel(int level) {
271     switch (level) {
272       case Logger.DEBUG:
273         return "DEBUG";
274       case Logger.INFO:
275         return "INFO";
276       case Logger.WARN:
277         return "WARN";
278       case Logger.ERROR:
279         return "ERROR";
280       case Logger.FATAL:
281         return "FATAL";
282       default:
283         return "UNKNOWN";
284     }
285   }
286
287
288   // Doesn't provide any credentials, used instead the default handler from jdk
289   // which reads credentials them from stdin.
290   public static class EmptySecurityCallbackHandler implements CallbackHandler {
291     @Override
292     public void handle(final Callback[] callbacks) throws UnsupportedCallbackException {
293       if (callbacks.length > 0) {
294         throw new UnsupportedCallbackException(callbacks[0], "Unsupported callback");
295       }
296     }
297
298     static void install() {
299       Security.setProperty("auth.login.defaultCallbackHandler", EmptySecurityCallbackHandler.class.getName());
300     }
301   }
302
303
304   // Need to wrap jsch config to workaround its bugs:
305   // https://bugs.eclipse.org/bugs/show_bug.cgi?id=526778
306   // https://bugs.eclipse.org/bugs/show_bug.cgi?id=526867
307   private static class TeamCityConfigRepository implements ConfigRepository {
308     private final ConfigRepository myDelegate;
309     private final String myUser;
310     TeamCityConfigRepository(@NotNull ConfigRepository delegate, @Nullable String user) {
311       myDelegate = delegate;
312       myUser = user;
313     }
314
315     @Override
316     public Config getConfig(final String host) {
317       Config config = myDelegate.getConfig(host);
318       return config != null ? new TeamCityConfig(config, myUser) : null;
319     }
320   }
321
322   private static class TeamCityConfig implements ConfigRepository.Config {
323     private final ConfigRepository.Config myDelegate;
324     private final String myUser;
325     TeamCityConfig(@NotNull ConfigRepository.Config delegate, @Nullable String user) {
326       myDelegate = delegate;
327       myUser = user;
328     }
329
330     @Override
331     public String getHostname() {
332       return myDelegate.getHostname();
333     }
334
335     @Override
336     public String getUser() {
337       // https://bugs.eclipse.org/bugs/show_bug.cgi?id=526778
338       // enforce our username
339       return myUser != null ? myUser : myDelegate.getUser();
340     }
341
342     @Override
343     public int getPort() {
344       return myDelegate.getPort();
345     }
346
347     @Override
348     public String getValue(final String key) {
349       String result = myDelegate.getValue(key);
350       if (result != null) {
351         if ("ServerAliveInterval".equalsIgnoreCase(key) || "ConnectTimeout".equalsIgnoreCase(key)) {
352           // https://bugs.eclipse.org/bugs/show_bug.cgi?id=526867
353           // these timeouts are in seconds, jsch treats them as milliseconds which causes timeout errors
354           try {
355             result = Long.toString(TimeUnit.SECONDS.toMillis(Integer.parseInt(result)));
356           } catch (NumberFormatException e) {
357             // Ignore
358           }
359         }
360       }
361       return result;
362     }
363
364     @Override
365     public String[] getValues(final String key) {
366       return myDelegate.getValues(key);
367     }
368   }
369 }