| /* |
| * Copyright 2000-2012 JetBrains s.r.o. |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| package org.jetbrains.git4idea.ssh; |
| |
| import com.intellij.openapi.util.io.FileUtilRt; |
| import com.intellij.util.ArrayUtilRt; |
| import com.trilead.ssh2.*; |
| import com.trilead.ssh2.crypto.PEMDecoder; |
| import org.jetbrains.annotations.NonNls; |
| import org.jetbrains.annotations.Nullable; |
| import org.jetbrains.git4idea.GitExternalApp; |
| |
| import java.io.*; |
| import java.util.*; |
| import java.util.concurrent.Semaphore; |
| |
| /** |
| * The main class for SSH client. It can only handle the following command line (which is use by GIT): |
| * git-ssh xmlRpcPort [-p port] host command. The program is wrapped in the script, so XML RPC port |
| * not settable using script interface. |
| * <p/> |
| * The code here is based on SwingShell example. |
| */ |
| @SuppressWarnings({"CallToPrintStackTrace", "UseOfSystemOutOrSystemErr"}) |
| public class SSHMain implements GitExternalApp { |
| /** |
| * the semaphore |
| */ |
| private final Semaphore myForwardCompleted = new Semaphore(0); |
| /** |
| * the host |
| */ |
| private final SSHConfig.Host myHost; |
| /** |
| * Handler number |
| */ |
| private final int myHandlerNo; |
| /** |
| * the xml RPC port |
| */ |
| private final GitSSHXmlRpcClient myXmlRpcClient; |
| /** |
| * the command to run |
| */ |
| private final String myCommand; |
| /** |
| * the exit code |
| */ |
| private int myExitCode = 0; |
| /** |
| * The last error |
| */ |
| private String myLastError = ""; |
| private Exception myErrorCause; |
| /** |
| * Path to known hosts file |
| */ |
| @NonNls private static final String knownHostPath = SSHConfig.USER_HOME + "/.ssh/known_hosts"; |
| /** |
| * Path to DSA key |
| */ |
| @NonNls private static final String idDSAPath = SSHConfig.USER_HOME + "/.ssh/id_dsa"; |
| /** |
| * Path to RSA key |
| */ |
| @NonNls private static final String idRSAPath = SSHConfig.USER_HOME + "/.ssh/id_rsa"; |
| |
| /** |
| * database of known hosts |
| */ |
| private final KnownHosts database = new KnownHosts(); |
| /** |
| * size of the buffers for stream forwarding |
| */ |
| private static final int BUFFER_SIZE = 16 * 1024; |
| /** |
| * public key authentication method |
| */ |
| @NonNls public static final String PUBLIC_KEY_METHOD = "publickey"; |
| /** |
| * keyboard interactive method |
| */ |
| @NonNls public static final String KEYBOARD_INTERACTIVE_METHOD = "keyboard-interactive"; |
| /** |
| * password method |
| */ |
| @NonNls public static final String PASSWORD_METHOD = "password"; |
| /** |
| * RSA algorithm |
| */ |
| @NonNls public static final String SSH_RSA_ALGORITHM = "ssh-rsa"; |
| /** |
| * DSS algorithm |
| */ |
| @NonNls public static final String SSH_DSS_ALGORITHM = "ssh-dss"; |
| |
| |
| /** |
| * A constructor |
| * |
| * @param host a host |
| * @param username a name of user (from URL) |
| * @param port a port |
| * @param command a command |
| * @throws IOException if config file could not be loaded |
| */ |
| private SSHMain(String host, String username, Integer port, String command) throws IOException { |
| SSHConfig config = SSHConfig.load(); |
| myHost = config.lookup(username, host, port); |
| myHandlerNo = Integer.parseInt(System.getenv(GitSSHHandler.SSH_HANDLER_ENV)); |
| int xmlRpcPort = Integer.parseInt(System.getenv(GitSSHHandler.SSH_PORT_ENV)); |
| myXmlRpcClient = new GitSSHXmlRpcClient(xmlRpcPort, myHost.isBatchMode()); |
| myCommand = command; |
| } |
| |
| /** |
| * The application entry point |
| * |
| * @param args program arguments |
| */ |
| public static void main(String[] args) { |
| try { |
| SSHMain app = parseArguments(args); |
| app.start(); |
| System.exit(app.myExitCode); |
| } |
| catch (Throwable t) { |
| t.printStackTrace(); |
| System.exit(1); |
| } |
| } |
| |
| /** |
| * Start the application |
| * |
| * @throws IOException if there is a problem with connection |
| * @throws InterruptedException if thread was interrupted |
| */ |
| private void start() throws IOException, InterruptedException { |
| Connection c = new Connection(myHost.getHostName(), myHost.getPort()); |
| try { |
| configureKnownHosts(c); |
| |
| boolean useHttpProxy = Boolean.valueOf(System.getenv(GitSSHHandler.SSH_USE_PROXY_ENV)); |
| if (useHttpProxy) { |
| String proxyHost = System.getenv(GitSSHHandler.SSH_PROXY_HOST_ENV); |
| Integer proxyPort = Integer.valueOf(System.getenv(GitSSHHandler.SSH_PROXY_PORT_ENV)); |
| boolean proxyAuthentication = Boolean.valueOf(System.getenv(GitSSHHandler.SSH_PROXY_AUTHENTICATION_ENV)); |
| String proxyUser = null; |
| String proxyPassword = null; |
| if (proxyAuthentication) { |
| proxyUser = System.getenv(GitSSHHandler.SSH_PROXY_USER_ENV); |
| proxyPassword = System.getenv(GitSSHHandler.SSH_PROXY_PASSWORD_ENV); |
| } |
| c.setProxyData(new HTTPProxyData(proxyHost, proxyPort, proxyUser, proxyPassword)); |
| } |
| |
| c.connect(new HostKeyVerifier()); |
| authenticate(c); |
| final Session s = c.openSession(); |
| try { |
| s.execCommand(myCommand); |
| // Note that stdin is not being waited using semaphore. |
| // Instead, the SSH process waits for remote process exit |
| // if remote process exited, none is interested in stdin |
| // anyway. |
| forward("stdin", s.getStdin(), System.in, false); |
| forward("stdout", System.out, s.getStdout(), true); |
| forward("stderr", System.err, s.getStderr(), true); |
| myForwardCompleted.acquire(2); // wait only for stderr and stdout |
| s.waitForCondition(ChannelCondition.EXIT_STATUS, Long.MAX_VALUE); |
| Integer exitStatus = s.getExitStatus(); |
| if (exitStatus == null) { |
| // broken exit status |
| exitStatus = 1; |
| } |
| System.exit(exitStatus.intValue() == 0 ? myExitCode : exitStatus.intValue()); |
| } |
| finally { |
| s.close(); |
| } |
| } |
| finally { |
| c.close(); |
| } |
| } |
| |
| /** |
| * Authenticate using some supported methods. If authentication fails, |
| * the method throws {@link IOException}. |
| * |
| * @param c the connection to use for authentication |
| * @throws IOException in case of IO error or authentication failure |
| */ |
| private void authenticate(final Connection c) throws IOException { |
| LinkedList<String> methods = new LinkedList<String>(myHost.getPreferredMethods()); |
| //log("authenticating... " + this); |
| String lastSuccessfulMethod = myXmlRpcClient.getLastSuccessful(myHandlerNo, getUserHostString()); |
| //log("SSH: authentication methods: " + methods + " last successful method: " + lastSuccessfulMethod); |
| if (lastSuccessfulMethod != null && lastSuccessfulMethod.length() > 0 && methods.remove(lastSuccessfulMethod)) { |
| methods.addFirst(lastSuccessfulMethod); |
| } |
| for (String method : methods) { |
| if (c.isAuthenticationComplete()) { |
| return; |
| } |
| if (PUBLIC_KEY_METHOD.equals(method)) { |
| if (!myHost.supportsPubkeyAuthentication()) { |
| continue; |
| } |
| if (!c.isAuthMethodAvailable(myHost.getUser(), PUBLIC_KEY_METHOD)) { |
| continue; |
| } |
| File key = myHost.getIdentityFile(); |
| if (key == null) { |
| for (String a : myHost.getHostKeyAlgorithms()) { |
| if (SSH_RSA_ALGORITHM.equals(a)) { |
| if (tryPublicKey(c, idRSAPath)) { |
| return; |
| } |
| } |
| else if (SSH_DSS_ALGORITHM.equals(a)) { |
| if (tryPublicKey(c, idDSAPath)) { |
| return; |
| } |
| } |
| } |
| } |
| else { |
| if (tryPublicKey(c, key.getPath())) { |
| return; |
| } |
| } |
| } |
| else if (KEYBOARD_INTERACTIVE_METHOD.equals(method)) { |
| if (!c.isAuthMethodAvailable(myHost.getUser(), KEYBOARD_INTERACTIVE_METHOD)) { |
| continue; |
| } |
| InteractiveSupport interactiveSupport = new InteractiveSupport(); |
| for (int i = myHost.getNumberOfPasswordPrompts(); i > 0; i--) { |
| if (c.isAuthenticationComplete()) { |
| return; |
| } |
| if (c.authenticateWithKeyboardInteractive(myHost.getUser(), interactiveSupport)) { |
| myLastError = ""; |
| myXmlRpcClient.setLastSuccessful(myHandlerNo, getUserHostString(), KEYBOARD_INTERACTIVE_METHOD, ""); |
| return; |
| } |
| else { |
| myLastError = SSHMainBundle.getString("sshmain.keyboard.interactive.failed"); |
| } |
| if (interactiveSupport.myPromptCount == 0 || interactiveSupport.myCancelled) { |
| // the interactive callback has never been asked or it was cancelled, exit the loop |
| myLastError = ""; |
| break; |
| } |
| } |
| } |
| else if (PASSWORD_METHOD.equals(method)) { |
| if (!myHost.supportsPasswordAuthentication()) { |
| continue; |
| } |
| if (!c.isAuthMethodAvailable(myHost.getUser(), PASSWORD_METHOD)) { |
| continue; |
| } |
| for (int i = 0; i < myHost.getNumberOfPasswordPrompts(); i++) { |
| String password = myXmlRpcClient.askPassword(myHandlerNo, getUserHostString(), i != 0, myLastError); |
| if (password == null) { |
| break; |
| } |
| else { |
| if (c.authenticateWithPassword(myHost.getUser(), password)) { |
| myLastError = ""; |
| myXmlRpcClient.setLastSuccessful(myHandlerNo, getUserHostString(), PASSWORD_METHOD, ""); |
| return; |
| } |
| else { |
| myLastError = SSHMainBundle.getString("sshmain.password.failed"); |
| } |
| } |
| } |
| } |
| } |
| myXmlRpcClient.setLastSuccessful(myHandlerNo, getUserHostString(), "", myLastError); |
| throw new IOException("Authentication failed: " + myLastError, myErrorCause); |
| } |
| |
| /** |
| * @return user and host string |
| */ |
| private String getUserHostString() { |
| int port = myHost.getPort(); |
| return myHost.getUser() + "@" + myHost.getHostName() + (port == 22 ? "" : ":" + port); |
| } |
| |
| /** |
| * Try public key |
| * |
| * @param c a ssh connection |
| * @param keyPath a path to key |
| * @return true if authentication is successful |
| */ |
| private boolean tryPublicKey(final Connection c, final String keyPath) { |
| try { |
| final File file = new File(keyPath); |
| if (file.exists()) { |
| // if encrypted ask user for passphrase |
| String passphrase = null; |
| char[] text = FileUtilRt.loadFileText(file); |
| if (isEncryptedKey(text)) { |
| // need to ask passphrase from user |
| int i; |
| for (i = 0; i < myHost.getNumberOfPasswordPrompts(); i++) { |
| passphrase = myXmlRpcClient.askPassphrase(myHandlerNo, getUserHostString(), keyPath, i != 0, myLastError); |
| if (passphrase == null) { |
| // if no passphrase was entered, just return false and try something other |
| return false; |
| } |
| else { |
| try { |
| PEMDecoder.decode(text, passphrase); |
| myLastError = ""; |
| } |
| catch (IOException e) { |
| // decoding failed |
| myLastError = SSHMainBundle.message("sshmain.invalidpassphrase", keyPath); |
| myErrorCause = e; |
| continue; |
| } |
| break; |
| } |
| } |
| if (i == myHost.getNumberOfPasswordPrompts()) { |
| myLastError = SSHMainBundle.message("sshmain.too.mush.passphrase.guesses", keyPath, myHost.getNumberOfPasswordPrompts()); |
| return false; |
| } |
| } |
| // try authentication |
| if (c.authenticateWithPublicKey(myHost.getUser(), text, passphrase)) { |
| myLastError = ""; |
| myXmlRpcClient.setLastSuccessful(myHandlerNo, getUserHostString(), PUBLIC_KEY_METHOD, ""); |
| return true; |
| } |
| else { |
| if (passphrase != null) { |
| // mark as failed authentication only if passphrase were asked |
| myLastError = SSHMainBundle.message("sshmain.pk.authenitication.failed", keyPath); |
| } |
| else { |
| myLastError = ""; |
| } |
| } |
| } |
| return false; |
| } |
| catch (Exception e) { |
| myErrorCause = e; |
| return false; |
| } |
| } |
| |
| /** |
| * Check if the key is encrypted. The key is considered encrypted |
| * |
| * @param text the text of the key |
| * @return true if the key is encrypted |
| * @throws IOException if there is a problem with reading key |
| */ |
| private static boolean isEncryptedKey(char[] text) throws IOException { |
| BufferedReader in = new BufferedReader(new CharArrayReader(text)); |
| try { |
| String line; |
| while ((line = in.readLine()) != null) { |
| //noinspection HardCodedStringLiteral |
| if (line.startsWith("Proc-Type: ") && line.contains("ENCRYPTED")) { |
| return true; |
| } |
| if (line.length() == 0) { |
| // empty line means end of the mime headers |
| break; |
| } |
| } |
| return false; |
| } |
| finally { |
| in.close(); |
| } |
| } |
| |
| /** |
| * Forward stream in separate thread. |
| * |
| * @param name the name of the stream |
| * @param out the output stream |
| * @param in the input stream |
| * @param releaseSemaphore if true the semaphore will be released |
| */ |
| private void forward(@NonNls final String name, final OutputStream out, final InputStream in, final boolean releaseSemaphore) { |
| final Runnable action = new Runnable() { |
| public void run() { |
| byte[] buffer = new byte[BUFFER_SIZE]; |
| int rc; |
| try { |
| try { |
| try { |
| while ((rc = in.read(buffer)) != -1) { |
| out.write(buffer, 0, rc); |
| } |
| } |
| finally { |
| out.close(); |
| } |
| } |
| finally { |
| in.close(); |
| } |
| } |
| catch (IOException e) { |
| System.err.println(SSHMainBundle.message("sshmain.forwarding.failed", name, e.getMessage())); |
| e.printStackTrace(); |
| myExitCode = 1; |
| if (releaseSemaphore) { |
| // in the case of error, release semaphore, so that application could exit |
| myForwardCompleted.release(1); |
| } |
| } |
| finally { |
| if (releaseSemaphore) { |
| myForwardCompleted.release(1); |
| } |
| } |
| } |
| }; |
| @SuppressWarnings({"HardCodedStringLiteral"}) final Thread t = new Thread(action, "Forwarding " + name); |
| t.setDaemon(true); |
| t.start(); |
| } |
| |
| |
| /** |
| * Configure known host database for connection |
| * |
| * @param c a connection |
| * @throws IOException if there is a IO problem |
| */ |
| private void configureKnownHosts(Connection c) throws IOException { |
| File knownHostFile = new File(knownHostPath); |
| if (knownHostFile.exists()) { |
| database.addHostkeys(knownHostFile); |
| } |
| final List<String> algorithms = myHost.getHostKeyAlgorithms(); |
| c.setServerHostKeyAlgorithms(ArrayUtilRt.toStringArray(algorithms)); |
| } |
| |
| /** |
| * Parse command line arguments and create application instance. |
| * |
| * @param args command line arguments |
| * @return application instance |
| * @throws IOException if loading configuration file failed |
| */ |
| private static SSHMain parseArguments(String[] args) throws IOException { |
| if (args.length != 2 && args.length != 4) { |
| System.err.println(SSHMainBundle.message("sshmain.invalid.amount.of.arguments", Arrays.asList(args))); |
| System.exit(1); |
| } |
| int i = 0; |
| Integer port = null; |
| //noinspection HardCodedStringLiteral |
| if ("-p".equals(args[i])) { |
| i++; |
| port = Integer.parseInt(args[i++]); |
| } |
| String host = args[i++]; |
| String user; |
| int atIndex = host.lastIndexOf('@'); |
| if (atIndex == -1) { |
| user = null; |
| } |
| else { |
| user = host.substring(0, atIndex); |
| host = host.substring(atIndex + 1); |
| } |
| String command = args[i]; |
| return new SSHMain(host, user, port, command); |
| } |
| |
| |
| /** |
| * Interactive callback support. The callback invokes Idea XML RPC server. |
| */ |
| class InteractiveSupport implements InteractiveCallback { |
| /** |
| * Prompt count |
| */ |
| int myPromptCount = 0; |
| /** |
| * true if keyboard interactive method was cancelled. |
| */ |
| boolean myCancelled; |
| |
| /** |
| * {@inheritDoc} |
| */ |
| @SuppressWarnings({"UseOfObsoleteCollectionType"}) |
| @Nullable |
| public String[] replyToChallenge(final String name, |
| final String instruction, |
| final int numPrompts, |
| final String[] prompt, |
| final boolean[] echo) throws Exception { |
| if (numPrompts == 0) { |
| return ArrayUtilRt.EMPTY_STRING_ARRAY; |
| } |
| myPromptCount++; |
| Vector<String> vPrompts = new Vector<String>(prompt.length); |
| Collections.addAll(vPrompts, prompt); |
| Vector<Boolean> vEcho = new Vector<Boolean>(prompt.length); |
| for (boolean e : echo) { |
| vEcho.add(e); |
| } |
| final Vector<String> result = |
| myXmlRpcClient.replyToChallenge(myHandlerNo, getUserHostString(), name, instruction, numPrompts, vPrompts, vEcho, myLastError); |
| if (result == null) { |
| myCancelled = true; |
| String[] rc = new String[numPrompts]; |
| Arrays.fill(rc, ""); |
| return rc; |
| } |
| else { |
| return ArrayUtilRt.toStringArray(result); |
| } |
| } |
| } |
| |
| /** |
| * Server host key verifier that invokes Idea XML RPC server. |
| */ |
| private class HostKeyVerifier implements ServerHostKeyVerifier { |
| /** |
| * {@inheritDoc} |
| */ |
| public boolean verifyServerHostKey(String hostname, int port, String serverHostKeyAlgorithm, byte[] serverHostKey) throws Exception { |
| try { |
| String s = System.getenv(GitSSHHandler.SSH_IGNORE_KNOWN_HOSTS_ENV); |
| if (s != null && Boolean.parseBoolean(s)) { |
| return true; |
| } |
| } |
| catch (Exception ex) { |
| // the known host check is not suppressed, proceed with normal check |
| } |
| try { |
| final int result = database.verifyHostkey(hostname, serverHostKeyAlgorithm, serverHostKey); |
| final boolean isNew; |
| switch (result) { |
| case KnownHosts.HOSTKEY_IS_OK: |
| return true; |
| case KnownHosts.HOSTKEY_IS_NEW: |
| isNew = true; |
| break; |
| case KnownHosts.HOSTKEY_HAS_CHANGED: |
| isNew = false; |
| break; |
| default: |
| throw new IllegalStateException("Unknown verification result: " + result); |
| } |
| String fingerprint = KnownHosts.createHexFingerprint(serverHostKeyAlgorithm, serverHostKey); |
| boolean keyCheck = myXmlRpcClient.verifyServerHostKey(myHandlerNo, hostname, port, serverHostKeyAlgorithm, fingerprint, isNew); |
| if (keyCheck) { |
| String hashedHostname = KnownHosts.createHashedHostname(hostname); |
| // Add the host key to the in-memory database |
| database.addHostkey(new String[]{hashedHostname}, serverHostKeyAlgorithm, serverHostKey); |
| // Also try to add the key to a known_host file |
| try { |
| KnownHosts.addHostkeyToFile(new File(knownHostPath), new String[]{hashedHostname}, serverHostKeyAlgorithm, serverHostKey); |
| } |
| catch (IOException ignore) { |
| // TODO log text |
| } |
| return true; |
| } |
| else { |
| System.err.println(SSHMainBundle.message("sshmain.invald.host.key", serverHostKeyAlgorithm, fingerprint)); |
| return false; |
| } |
| } |
| catch (Throwable t) { |
| System.err.println(SSHMainBundle.message("sshmain.failed.to.verify.key", t.getMessage())); |
| t.printStackTrace(); |
| return false; |
| } |
| } |
| } |
| |
| @Override |
| public String toString() { |
| return String |
| .format("SSHMain{myHost=%s, myHandlerNo=%d, myCommand='%s', myExitCode=%d, myLastError='%s'}", myHost, myHandlerNo, myCommand, |
| myExitCode, myLastError); |
| } |
| |
| private static void log(String s) { |
| System.err.println(s); |
| } |
| } |