2 * Copyright 2000-2018 JetBrains s.r.o.
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
8 * http://www.apache.org/licenses/LICENSE-2.0
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.
17 package jetbrains.buildServer.buildTriggers.vcs.git.agent;
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;
25 import javax.security.auth.callback.Callback;
26 import javax.security.auth.callback.CallbackHandler;
27 import javax.security.auth.callback.UnsupportedCallbackException;
29 import java.io.IOException;
30 import java.io.InputStream;
31 import java.security.Security;
32 import java.text.SimpleDateFormat;
34 import java.util.concurrent.TimeUnit;
35 import java.util.concurrent.atomic.AtomicLong;
37 public class JSchClient {
39 private final static int BUF_SIZE = 32 * 1024;
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;
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) {
55 myUsername = username;
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);
67 JSchClient ssh = createClient(logger, args);
69 } catch (Throwable t) {
70 if (logger instanceof InMemoryLogger) {
71 ((InMemoryLogger)logger).printLog();
73 System.err.println(t.getMessage());
74 if (t instanceof NullPointerException || debug)
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):
84 // $GIT_SSH [-p <port>] [username@]host <command>
88 // $GIT_SSH 'git@server.com' 'git-upload-pack '\''user/repo.git'\'''
90 // The git-upload-pack command and its args are passed as a single argument to $GIT_SSH.
92 // Git LFS also uses $GIT_SSH, but it doesn't combine all arguments into a single $GIT_SSH arg:
94 // $GIT_SSH 'git@server.com' 'git-lfs-authenticate' 'user/repo.git' 'download'
96 // we need to combine them ourselves.
98 final Map<String, String> options = new HashMap<String, String>();
102 if ("-o".equals(args[i])) {
104 final String[] op = args[i++].split("=");
105 options.put(op[0], op[1]);
109 //noinspection HardCodedStringLiteral
110 if ("-p".equals(args[i])) {
112 port = Integer.parseInt(args[i++]);
114 String host = args[i++];
116 int atIndex = host.lastIndexOf('@');
121 user = host.substring(0, atIndex);
122 host = host.substring(atIndex + 1);
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]);
131 command = commandWithArguments.toString();
133 return new JSchClient(host, user, port, command, logger, options);
137 public void run() throws Exception {
138 myLogger.log(Logger.INFO, "SSH command to run: " + myCommand);
139 ChannelExec channel = null;
140 Session session = null;
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));
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");
154 jsch.addIdentity(rsa.getAbsolutePath());
156 File dsa = new File(ssh, "id_dsa");
158 jsch.addIdentity(dsa.getAbsolutePath());
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));
167 session = jsch.getSession(myUsername, myHost, myPort != null ? myPort : 22);
169 String teamCityVersion = System.getenv(GitSSHHandler.TEAMCITY_VERSION);
170 if (teamCityVersion != null) {
171 session.setClientVersion(GitUtils.getSshClientVersion(session.getClientVersion(), teamCityVersion));
174 if (Boolean.parseBoolean(System.getenv(GitSSHHandler.SSH_IGNORE_KNOWN_HOSTS_ENV))) {
175 session.setConfig("StrictHostKeyChecking", "no");
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()) {
184 jsch.setKnownHosts(knownHosts.getAbsolutePath());
185 } catch (Exception e) {
186 myLogger.log(Logger.WARN, "Failed to configure known hosts: '" + e.toString() + "'");
192 String authMethods = System.getenv(GitSSHHandler.TEAMCITY_SSH_PREFERRED_AUTH_METHODS);
193 if (authMethods != null && authMethods.length() > 0)
194 session.setConfig("PreferredAuthentications", authMethods);
196 if (!myOptions.isEmpty()) {
197 for (final Map.Entry<String, String> opEntry : myOptions.entrySet()) {
198 session.setConfig(opEntry.getKey(), opEntry.getValue());
202 EmptySecurityCallbackHandler.install();
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);
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);
225 if (!channel.isConnected()) {
226 throw new IOException("Connection failed");
229 Copy copyThread = new Copy(input);
230 if (timeoutSeconds != null) {
231 new Timer(copyThread, timeoutSeconds).start();
235 copyThread.rethrowError();
238 channel.disconnect();
240 session.disconnect();
246 private Integer getTimeoutSeconds() {
247 String timeout = System.getenv(GitSSHHandler.TEAMCITY_SSH_IDLE_TIMEOUT_SECONDS);
251 return Integer.parseInt(timeout);
252 } catch (NumberFormatException e) {
253 myLogger.log(Logger.WARN, "Failed to parse idle timeout: '" + timeout + "'");
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);
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
275 if (System.nanoTime() - myCopyThread.getTimestamp() > myThresholdNanos) {
277 myLogger.log(Logger.ERROR, String.format("Timeout error: no activity for %s seconds", TimeUnit.NANOSECONDS.toSeconds(myThresholdNanos)));
280 myCopyThread.interrupt();
283 Thread.sleep(sleepInterval);
284 } catch (Exception e) {
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) {
304 byte[] buffer = new byte[BUF_SIZE];
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");
315 } catch (Exception e) {
320 long getTimestamp() {
321 return myTimestamp.get();
324 void rethrowError() throws Exception {
331 private static class StdErrLogger implements Logger {
332 private final SimpleDateFormat myDateFormat = new SimpleDateFormat("[HH:mm:ss.SSS]");
334 public boolean isEnabled(final int level) {
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);
348 private String getTimestamp() {
349 synchronized (myDateFormat) {
350 return myDateFormat.format(new Date());
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>();
365 public boolean isEnabled(final int level) {
366 return level >= myMinLogLevel;
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));
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);
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;
405 private static String getLevel(int level) {
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 {
427 public void handle(final Callback[] callbacks) throws UnsupportedCallbackException {
428 if (callbacks.length > 0) {
429 throw new UnsupportedCallbackException(callbacks[0], "Unsupported callback");
433 static void install() {
434 Security.setProperty("auth.login.defaultCallbackHandler", EmptySecurityCallbackHandler.class.getName());
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;
451 public Config getConfig(final String host) {
452 Config config = myDelegate.getConfig(host);
453 return config != null ? new TeamCityConfig(config, myUser) : null;
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;
466 public String getHostname() {
467 return myDelegate.getHostname();
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();
478 public int getPort() {
479 return myDelegate.getPort();
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
490 result = Long.toString(TimeUnit.SECONDS.toMillis(Integer.parseInt(result)));
491 } catch (NumberFormatException e) {
500 public String[] getValues(final String key) {
501 return myDelegate.getValues(key);