TW-52308 add TeamCity version to ssh client version on server
[teamcity/git-plugin.git] / git-server / src / jetbrains / buildServer / buildTriggers / vcs / git / TransportFactoryImpl.java
1 /*
2  * Copyright 2000-2014 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;
18
19 import com.intellij.openapi.diagnostic.Logger;
20 import com.intellij.openapi.util.SystemInfo;
21 import com.jcraft.jsch.Cipher;
22 import com.jcraft.jsch.JSch;
23 import com.jcraft.jsch.JSchException;
24 import com.jcraft.jsch.Session;
25 import jetbrains.buildServer.ssh.TeamCitySshKey;
26 import jetbrains.buildServer.ssh.VcsRootSshKeyManager;
27 import jetbrains.buildServer.vcs.VcsException;
28 import jetbrains.buildServer.vcs.VcsRoot;
29 import jetbrains.buildServer.version.ServerVersionHolder;
30 import jetbrains.buildServer.version.ServerVersionInfo;
31 import org.eclipse.jgit.errors.NotSupportedException;
32 import org.eclipse.jgit.errors.TransportException;
33 import org.eclipse.jgit.lib.Repository;
34 import org.eclipse.jgit.transport.*;
35 import org.eclipse.jgit.transport.http.HttpConnectionFactory;
36 import org.eclipse.jgit.transport.http.apache.HttpClientConnectionFactory;
37 import org.eclipse.jgit.util.FS;
38 import org.jetbrains.annotations.NotNull;
39 import org.jetbrains.annotations.Nullable;
40
41 import java.io.File;
42 import java.util.*;
43
44 import static com.intellij.openapi.util.text.StringUtil.isEmpty;
45 import static java.util.Collections.emptySet;
46
47 /**
48 * @author dmitry.neverov
49 */
50 public class TransportFactoryImpl implements TransportFactory {
51
52   private static Logger LOG = Logger.getInstance(TransportFactoryImpl.class.getName());
53
54   private final ServerPluginConfig myConfig;
55   private final Map<String,String> myJSchOptions;
56   private final VcsRootSshKeyManager mySshKeyManager;
57
58   public TransportFactoryImpl(@NotNull ServerPluginConfig config,
59                               @NotNull VcsRootSshKeyManager sshKeyManager) {
60     myConfig = config;
61     myJSchOptions = getJSchCipherOptions();
62     mySshKeyManager = sshKeyManager;
63     String factoryName = myConfig.getHttpConnectionFactory();
64     HttpConnectionFactory f;
65     if ("httpClient".equals(factoryName)) {
66       f = new SNIHttpClientConnectionFactory();
67     } else if ("httpClientNoSNI".equals(factoryName)) {
68       f = new HttpClientConnectionFactory();
69     } else {
70       f = new TeamCityJDKHttpConnectionFactory(myConfig);
71     }
72     HttpTransport.setConnectionFactory(f);
73   }
74
75   public Transport createTransport(@NotNull Repository r, @NotNull URIish url, @NotNull AuthSettings authSettings) throws NotSupportedException, VcsException {
76     return createTransport(r, url, authSettings, myConfig.getIdleTimeoutSeconds());
77   }
78
79
80   public Transport createTransport(@NotNull final Repository r,
81                                    @NotNull final URIish url,
82                                    @NotNull final AuthSettings authSettings,
83                                    final int timeoutSeconds) throws NotSupportedException, VcsException {
84     try {
85       checkUrl(url);
86       URIish preparedURI = prepareURI(url);
87       final Transport t = Transport.open(r, preparedURI);
88       t.setCredentialsProvider(authSettings.toCredentialsProvider());
89       if (t instanceof SshTransport) {
90         SshTransport ssh = (SshTransport)t;
91         ssh.setSshSessionFactory(getSshSessionFactory(authSettings, url));
92       }
93       t.setTimeout(timeoutSeconds);
94       return t;
95     } catch (TransportException e) {
96       throw new VcsException("Cannot create transport", e);
97     }
98   }
99
100   @NotNull
101   private URIish prepareURI(@NotNull URIish uri) {
102     final String scheme = uri.getScheme();
103     //Remove a username from the http URI. A Username can contain forbidden
104     //characters, e.g. backslash (TW-21747). A username and a password will
105     //be supplied by CredentialProvider
106     if ("http".equals(scheme) || "https".equals(scheme))
107       return uri.setUser(null);
108     return uri;
109   }
110
111
112   /**
113    * This is a work-around for an issue http://youtrack.jetbrains.net/issue/TW-9933.
114    * Due to bug in jgit (https://bugs.eclipse.org/bugs/show_bug.cgi?id=315564),
115    * in the case of not-existing local repository we get an obscure exception:
116    * 'org.eclipse.jgit.errors.NotSupportedException: URI not supported: x:/git/myrepo.git',
117    * while URI is correct.
118    *
119    * It often happens when people try to access a repository located on a mapped network
120    * drive from the TeamCity started as Windows service.
121    *
122    * If repository is local and is not exists this method throws a friendly exception.
123    *
124    * @param url URL to check
125    * @throws VcsException if url points to not-existing local repository
126    */
127   private void checkUrl(final URIish url) throws VcsException {
128     String scheme = url.getScheme();
129     if (!url.isRemote() && !"http".equals(scheme) && !"https".equals(scheme)) {
130       File localRepository = new File(url.getPath());
131       if (!localRepository.exists()) {
132         String error = "Cannot access the '" + url.toString() + "' repository";
133         if (SystemInfo.isWindows) {
134           error += ". If TeamCity is run as a Windows service, it cannot access network mapped drives. Make sure this is not your case.";
135         }
136         throw new VcsException(error);
137       }
138     }
139   }
140
141
142   /**
143    * Get appropriate session factory object for specified settings and url
144    *
145    * @param authSettings a vcs root settings
146    * @param url URL of interest
147    * @return session factory object
148    * @throws VcsException in case of problems with creating object
149    */
150   private SshSessionFactory getSshSessionFactory(AuthSettings authSettings, URIish url) throws VcsException {
151     switch (authSettings.getAuthMethod()) {
152       case PRIVATE_KEY_DEFAULT:
153         return new DefaultJschConfigSessionFactory(myConfig, authSettings, myJSchOptions);
154       case PRIVATE_KEY_FILE:
155         return new CustomPrivateKeySessionFactory(myConfig, authSettings, myJSchOptions);
156       case TEAMCITY_SSH_KEY:
157         return new TeamCitySshKeySessionFactory(myConfig, authSettings, myJSchOptions, mySshKeyManager);
158       case PASSWORD:
159         return new PasswordJschConfigSessionFactory(myConfig, authSettings, myJSchOptions);
160       default:
161         final AuthenticationMethod method = authSettings.getAuthMethod();
162         final String methodName = method == null ? "<null>" : method.uiName();
163         throw new VcsAuthenticationException(url.toString(), "The authentication method " + methodName + " is not supported for SSH, please provide SSH key or credentials");
164     }
165   }
166
167
168   private static class DefaultJschConfigSessionFactory extends JschConfigSessionFactory {
169     protected final ServerPluginConfig myConfig;
170     protected final AuthSettings myAuthSettings;
171     private final Map<String,String> myJschOptions;
172
173     private DefaultJschConfigSessionFactory(@NotNull ServerPluginConfig config,
174                                             @NotNull AuthSettings authSettings,
175                                             @NotNull Map<String,String> jschOptions) {
176       myConfig = config;
177       myAuthSettings = authSettings;
178       myJschOptions = jschOptions;
179     }
180
181     @Override
182     protected void configure(OpenSshConfig.Host hc, Session session) {
183       session.setProxy(myConfig.getJschProxy());//null proxy is allowed
184       if (myAuthSettings.isIgnoreKnownHosts())
185         session.setConfig("StrictHostKeyChecking", "no");
186       if (!myConfig.alwaysCheckCiphers()) {
187         for (Map.Entry<String, String> entry : myJschOptions.entrySet())
188           session.setConfig(entry.getKey(), entry.getValue());
189       }
190     }
191   }
192
193   private static class PasswordJschConfigSessionFactory extends DefaultJschConfigSessionFactory {
194
195     private PasswordJschConfigSessionFactory(@NotNull ServerPluginConfig config,
196                                              @NotNull AuthSettings authSettings,
197                                              @NotNull Map<String,String> jschOptions) {
198       super(config, authSettings, jschOptions);
199     }
200
201     @Override
202     protected void configure(OpenSshConfig.Host hc, Session session) {
203       super.configure(hc, session);
204       session.setPassword(myAuthSettings.getPassword());
205     }
206   }
207
208
209   private static class CustomPrivateKeySessionFactory extends DefaultJschConfigSessionFactory {
210
211     private CustomPrivateKeySessionFactory(@NotNull ServerPluginConfig config,
212                                            @NotNull AuthSettings authSettings,
213                                            @NotNull Map<String,String> jschOptions) {
214       super(config, authSettings, jschOptions);
215     }
216
217     @Override
218     protected JSch getJSch(OpenSshConfig.Host hc, FS fs) throws JSchException {
219       return createDefaultJSch(fs);
220     }
221
222     @Override
223     protected JSch createDefaultJSch(FS fs) throws JSchException {
224       final JSch jsch = new JSch();
225       jsch.addIdentity(myAuthSettings.getPrivateKeyFilePath(), myAuthSettings.getPassphrase());
226       return jsch;
227     }
228
229     @Override
230     protected void configure(OpenSshConfig.Host hc, Session session) {
231       super.configure(hc, session);
232       session.setConfig("StrictHostKeyChecking", "no");
233     }
234   }
235
236
237   private static class TeamCitySshKeySessionFactory extends DefaultJschConfigSessionFactory {
238
239     private final VcsRootSshKeyManager mySshKeyManager;
240
241     private TeamCitySshKeySessionFactory(@NotNull ServerPluginConfig config,
242                                          @NotNull AuthSettings authSettings,
243                                          @NotNull Map<String,String> jschOptions,
244                                          @NotNull VcsRootSshKeyManager sshKeyManager) {
245       super(config, authSettings, jschOptions);
246       mySshKeyManager = sshKeyManager;
247     }
248
249     @Override
250     protected JSch getJSch(OpenSshConfig.Host hc, FS fs) throws JSchException {
251       return createDefaultJSch(fs);
252     }
253
254     @Override
255     protected JSch createDefaultJSch(FS fs) throws JSchException {
256       final JSch jsch = new JSch();
257       final VcsRoot root = myAuthSettings.getRoot();
258       if (root != null) {
259         TeamCitySshKey sshKey = mySshKeyManager.getKey(root);
260         if (sshKey != null) {
261           try {
262             jsch.addIdentity("", sshKey.getPrivateKey(), null, myAuthSettings.getPassphrase() != null ? myAuthSettings.getPassphrase().getBytes() : null);
263           } catch (JSchException e) {
264             String keyName = root.getProperty(VcsRootSshKeyManager.VCS_ROOT_TEAMCITY_SSH_KEY_NAME);
265             if (keyName == null)
266               throw e;
267             throw new JSchException(getErrorMessage(keyName, e), e);
268           }
269         }
270       }
271       return jsch;
272     }
273
274     @NotNull
275     private String getErrorMessage(@NotNull String keyName, @NotNull JSchException e) {
276       String msg = e.getMessage();
277       if (msg == null) {
278         LOG.debug("Error while loading an uploaded key '" + keyName + "'", e);
279         return "Error while loading an uploaded key '" + keyName + "'";
280       }
281       int idx = msg.indexOf("[B@");
282       if (idx >= 0) {
283         msg = msg.substring(0, idx);
284         msg = msg.trim();
285         if (msg.endsWith(":"))
286           msg = msg.substring(0, msg.length() - 1);
287       }
288       return "Error while loading an uploaded key '" + keyName + "': " + msg;
289     }
290
291     @Override
292     protected void configure(OpenSshConfig.Host hc, Session session) {
293       super.configure(hc, session);
294       session.setConfig("StrictHostKeyChecking", "no");
295       String teamCityVersion = getTeamCityVersion();
296       if (teamCityVersion != null) {
297         session.setClientVersion(GitUtils.getSshClientVersion(session.getClientVersion(), teamCityVersion));
298       }
299     }
300   }
301
302
303   /**
304    * JSch checks available ciphers during session connect
305    * (inside method send_kexinit), which is expensive (see
306    * thread dumps attached to TW-18811). This method checks
307    * available ciphers and if they are found turns off checks
308    * inside JSch.
309    * @return map of cipher options for JSch which either
310    * specify found ciphers and turn off expensive cipher check,
311    * or, when no ciphers found, do nothing, so we will get
312    * exception from JSch with explanation of the problem
313    */
314   private Map<String, String> getJSchCipherOptions() {
315     LOG.debug("Check available ciphers");
316     try {
317       JSch jsch = new JSch();
318       Session session = jsch.getSession("", "");
319
320       String cipherc2s = session.getConfig("cipher.c2s");
321       String ciphers2c = session.getConfig("cipher.s2c");
322
323       Set<String> notAvailable = checkCiphers(session);
324       if (!notAvailable.isEmpty()) {
325         cipherc2s = diffString(cipherc2s, notAvailable);
326         ciphers2c = diffString(ciphers2c, notAvailable);
327         if (isEmpty(cipherc2s) || isEmpty(ciphers2c)) {
328           LOG.debug("No ciphers found, use default JSch options");
329           return new HashMap<String, String>();
330         }
331       }
332
333       LOG.debug("Turn off ciphers checks, use found ciphers cipher.c2s: " + cipherc2s + ", cipher.s2c: " + ciphers2c);
334       Map<String, String> options = new HashMap<String, String>();
335       options.put("cipher.c2s", cipherc2s);
336       options.put("cipher.s2c", ciphers2c);
337       options.put("CheckCiphers", "");//turn off ciphers check
338       return options;
339     } catch (JSchException e) {
340       LOG.debug("Error while ciphers check, use default JSch options", e);
341       return new HashMap<String, String>();
342     }
343   }
344
345   private Set<String> checkCiphers(@NotNull Session session) {
346     String ciphers = session.getConfig("CheckCiphers");
347     if (isEmpty(ciphers))
348       return emptySet();
349
350     Set<String> result = new HashSet<String>();
351     String[] _ciphers = ciphers.split(",");
352     for (String cipher : _ciphers) {
353       if (!checkCipher(session.getConfig(cipher)))
354         result.add(cipher);
355     }
356
357     return result;
358   }
359
360   private boolean checkCipher(String cipherClassName){
361     try {
362       Class klass = Class.forName(cipherClassName);
363       Cipher cipher = (Cipher)(klass.newInstance());
364       cipher.init(Cipher.ENCRYPT_MODE,
365                   new byte[cipher.getBlockSize()],
366                   new byte[cipher.getIVSize()]);
367       return true;
368     } catch(Exception e){
369       return false;
370     }
371   }
372
373   private String diffString(@NotNull String str, @NotNull Set<String> notAvailable) {
374     List<String> ciphers = new ArrayList<String>(Arrays.asList(str.split(",")));
375     ciphers.removeAll(notAvailable);
376
377     StringBuilder builder = new StringBuilder();
378     Iterator<String> iter = ciphers.iterator();
379     while (iter.hasNext()) {
380       String cipher = iter.next();
381       builder.append(cipher);
382       if (iter.hasNext())
383         builder.append(",");
384     }
385     return builder.toString();
386   }
387
388   @Nullable
389   private static String getTeamCityVersion() {
390     try {
391       ServerVersionInfo version = ServerVersionHolder.getVersion();
392       return "TeamCity Server " + version.getDisplayVersion();
393     } catch (Exception e) {
394       return null;
395     }
396   }
397 }