diff --git a/build.moxie b/build.moxie index bd1c92880..86171165d 100644 --- a/build.moxie +++ b/build.moxie @@ -114,8 +114,7 @@ properties: { bouncycastle.version : 1.81 selenium.version : 2.28.0 wikitext.version : 1.4 - sshd.version: 1.7.0 - mina.version: 2.0.27 + sshd.version: 2.14.0 guice.version : 5.1.0 # Gitblit maintains a fork of guice-servlet guice-servlet.version : 5.1.0-gb2 @@ -173,7 +172,6 @@ dependencies: - compile 'org.bouncycastle:bcpkix-jdk18on:${bouncycastle.version}' :war - compile 'net.i2p.crypto:eddsa:0.2.0' :war !org.easymock - compile 'org.apache.sshd:sshd-core:${sshd.version}' :war !org.easymock -- compile 'org.apache.mina:mina-core:${mina.version}' :war !org.easymock - compile 'rome:rome:0.9' :war :manager :api - compile 'com.google.code.gson:gson:2.10' :war :fedclient :manager :api - compile 'org.codehaus.groovy:groovy-all:${groovy.version}' :war diff --git a/src/main/java/com/gitblit/transport/ssh/DisabledFilesystemFactory.java b/src/main/java/com/gitblit/transport/ssh/DisabledFilesystemFactory.java index 9bab3b8ee..789be6d7d 100644 --- a/src/main/java/com/gitblit/transport/ssh/DisabledFilesystemFactory.java +++ b/src/main/java/com/gitblit/transport/ssh/DisabledFilesystemFactory.java @@ -17,21 +17,20 @@ import java.io.IOException; import java.nio.file.FileSystem; +import java.nio.file.Path; import org.apache.sshd.common.file.FileSystemFactory; -import org.apache.sshd.common.session.Session; +import org.apache.sshd.common.session.SessionContext; public class DisabledFilesystemFactory implements FileSystemFactory { - /** - * Create user specific file system. - * - * @param session The session created for the user - * @return The current {@link FileSystem} for the provided session - * @throws java.io.IOException when the filesystem can not be created - */ @Override - public FileSystem createFileSystem(Session session) throws IOException { - return null; + public Path getUserHomeDir(SessionContext session) throws IOException { + return null; + } + + @Override + public FileSystem createFileSystem(SessionContext session) throws IOException { + return null; } } diff --git a/src/main/java/com/gitblit/transport/ssh/FileKeyPairProvider.java b/src/main/java/com/gitblit/transport/ssh/FileKeyPairProvider.java index 0e97f557c..336a48dcd 100644 --- a/src/main/java/com/gitblit/transport/ssh/FileKeyPairProvider.java +++ b/src/main/java/com/gitblit/transport/ssh/FileKeyPairProvider.java @@ -1,20 +1,17 @@ /* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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 + * Copyright 2014 gitblit.com. * - * http://www.apache.org/licenses/LICENSE-2.0 + * 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 * - * 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. + * 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 com.gitblit.transport.ssh; @@ -29,8 +26,10 @@ import java.util.Iterator; import java.util.NoSuchElementException; +import org.apache.sshd.common.config.keys.FilePasswordProvider; import org.apache.sshd.common.config.keys.PrivateKeyEntryDecoder; import org.apache.sshd.common.keyprovider.AbstractKeyPairProvider; +import org.apache.sshd.common.session.SessionContext; import org.apache.sshd.common.util.security.SecurityUtils; import org.bouncycastle.openssl.PEMKeyPair; import org.bouncycastle.openssl.PEMParser; @@ -45,82 +44,62 @@ import org.bouncycastle.util.io.pem.PemObject; import org.bouncycastle.util.io.pem.PemReader; -/** - * This host key provider loads private keys from the specified files. - *

- * Note that this class has a direct dependency on BouncyCastle and won't work - * unless it has been correctly registered as a security provider. - * - * @author Apache MINA SSHD Project - */ -public class FileKeyPairProvider extends AbstractKeyPairProvider -{ +public class FileKeyPairProvider extends AbstractKeyPairProvider { private String[] files; - public FileKeyPairProvider() - { + public FileKeyPairProvider() { } - public FileKeyPairProvider(String[] files) - { + public FileKeyPairProvider(String[] files) { this.files = files; } - public String[] getFiles() - { + public String[] getFiles() { return files; } - public void setFiles(String[] files) - { + public void setFiles(String[] files) { this.files = files; } + public Iterable loadKeys() { + return loadKeys(null); + } @Override - public Iterable loadKeys() - { + public Iterable loadKeys(SessionContext session) { if (!SecurityUtils.isBouncyCastleRegistered()) { throw new IllegalStateException("BouncyCastle must be registered as a JCE provider"); } - return new Iterable() - { + return new Iterable() { @Override - public Iterator iterator() - { - return new Iterator() - { + public Iterator iterator() { + return new Iterator() { private final Iterator iterator = Arrays.asList(files).iterator(); private KeyPair nextKeyPair; - private boolean nextKeyPairSet = false; + private boolean nextKeyPairSet; @Override - public boolean hasNext() - { + public boolean hasNext() { return nextKeyPairSet || setNextObject(); } @Override - public KeyPair next() - { - if (!nextKeyPairSet) { - if (!setNextObject()) { - throw new NoSuchElementException(); - } + public KeyPair next() { + if (!nextKeyPairSet && !setNextObject()) { + throw new NoSuchElementException(); } nextKeyPairSet = false; return nextKeyPair; } @Override - public void remove() - { + public void remove() { throw new UnsupportedOperationException(); } - private boolean setNextObject() - { + private boolean setNextObject() { while (iterator.hasNext()) { String file = iterator.next(); File f = new File(file); @@ -136,80 +115,65 @@ private boolean setNextObject() } return false; } - }; } }; } - - private KeyPair doLoadKey(String file) - { + private KeyPair doLoadKey(String file) { try { - try (PemReader r = new PemReader(new InputStreamReader(new FileInputStream(file)))) { PemObject pemObject = r.readPemObject(); - if ("OPENSSH PRIVATE KEY".equals(pemObject.getType())) { - // This reads a properly OpenSSH formatted ed25519 private key file. - // It is currently unused because the SSHD library in play doesn't work with proper keys. - // This is kept in the hope that in the future the library offers proper support. + if (pemObject != null && "OPENSSH PRIVATE KEY".equals(pemObject.getType())) { try { byte[] privateKeyContent = pemObject.getContent(); AsymmetricKeyParameter privateKeyParameters = OpenSSHPrivateKeyUtil.parsePrivateKeyBlob(privateKeyContent); if (privateKeyParameters instanceof Ed25519PrivateKeyParameters) { OpenSSHPrivateKeySpec privkeySpec = new OpenSSHPrivateKeySpec(privateKeyContent); - - Ed25519PublicKeyParameters publicKeyParameters = ((Ed25519PrivateKeyParameters)privateKeyParameters).generatePublicKey(); - OpenSSHPublicKeySpec pubKeySpec = new OpenSSHPublicKeySpec(OpenSSHPublicKeyUtil.encodePublicKey(publicKeyParameters)); - + Ed25519PublicKeyParameters publicKeyParameters = + ((Ed25519PrivateKeyParameters) privateKeyParameters).generatePublicKey(); + OpenSSHPublicKeySpec pubKeySpec = + new OpenSSHPublicKeySpec(OpenSSHPublicKeyUtil.encodePublicKey(publicKeyParameters)); KeyFactory kf = KeyFactory.getInstance("Ed25519", "BC"); PrivateKey privateKey = kf.generatePrivate(privkeySpec); PublicKey publicKey = kf.generatePublic(pubKeySpec); return new KeyPair(publicKey, privateKey); } - else { - log.warn("OpenSSH format is only supported for Ed25519 key type. Unable to read key " + file); - } - } - catch (Exception e) { - log.warn("Unable to read key " + file, e); + log.warn("OpenSSH format is only supported for Ed25519 key type. Unable to read key {}", file); + } catch (Exception e) { + log.warn("Unable to read key {}", file, e); } return null; } - if ("EDDSA PRIVATE KEY".equals(pemObject.getType())) { - // This reads the ed25519 key from a file format that we created in SshDaemon. - // The type EDDSA PRIVATE KEY was given by us and nothing official. + if (pemObject != null && "EDDSA PRIVATE KEY".equals(pemObject.getType())) { byte[] privateKeyContent = pemObject.getContent(); - PrivateKeyEntryDecoder decoder = SecurityUtils.getOpenSSHEDDSAPrivateKeyEntryDecoder(); - PrivateKey privateKey = decoder.decodePrivateKey(null, privateKeyContent, 0, privateKeyContent.length); - PublicKey publicKey = SecurityUtils. recoverEDDSAPublicKey(privateKey); + PrivateKeyEntryDecoder decoder = + SecurityUtils.getOpenSSHEDDSAPrivateKeyEntryDecoder(); + PrivateKey privateKey = decoder.decodePrivateKey( + null, FilePasswordProvider.EMPTY, privateKeyContent, 0, privateKeyContent.length); + PublicKey publicKey = SecurityUtils.recoverEDDSAPublicKey(privateKey); return new KeyPair(publicKey, privateKey); } } try (PEMParser r = new PEMParser(new InputStreamReader(new FileInputStream(file)))) { Object o = r.readObject(); - JcaPEMKeyConverter pemConverter = new JcaPEMKeyConverter(); pemConverter.setProvider("BC"); if (o instanceof PEMKeyPair) { - o = pemConverter.getKeyPair((PEMKeyPair)o); - return (KeyPair)o; + return pemConverter.getKeyPair((PEMKeyPair) o); } - else if (o instanceof KeyPair) { - return (KeyPair)o; + if (o instanceof KeyPair) { + return (KeyPair) o; } - else { - log.warn("Cannot read unsupported PEM object of type: " + o.getClass().getCanonicalName()); + if (o != null) { + log.warn("Cannot read unsupported PEM object of type: {}", o.getClass().getCanonicalName()); } } - - } - catch (Exception e) { - log.warn("Unable to read key " + file, e); + } catch (Exception e) { + log.warn("Unable to read key {}", file, e); } return null; } - } diff --git a/src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java b/src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java index 9b494027c..37fb8772e 100644 --- a/src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java +++ b/src/main/java/com/gitblit/transport/ssh/LdapKeyManager.java @@ -30,9 +30,9 @@ import org.apache.sshd.common.config.keys.KeyUtils; import org.apache.sshd.common.util.GenericUtils; +import com.gitblit.Constants.AccessPermission; import com.gitblit.IStoredSettings; import com.gitblit.Keys; -import com.gitblit.Constants.AccessPermission; import com.gitblit.ldap.LdapConnection; import com.gitblit.models.UserModel; import com.gitblit.utils.StringUtils; @@ -43,393 +43,273 @@ import com.unboundid.ldap.sdk.SearchResult; import com.unboundid.ldap.sdk.SearchResultEntry; -/** - * LDAP-only public key manager - * - * Retrieves public keys from user's LDAP entries. Using this key manager, - * no SSH keys can be edited, i.e. added, removed, permissions changed, etc. - * - * This key manager supports SSH key entries in LDAP of the following form: - * [:] [] [] - * This follows the required form of entries in the authenticated_keys file, - * with an additional optional prefix. Key entries must have a key type - * (like "ssh-rsa") and a key, and may have a comment at the end. - * - * An entry may specify login options as specified for the authorized_keys file. - * The 'environment' option may be used to set the permissions for the key - * by setting a 'gbPerm' environment variable. The key manager will interpret - * such a environment variable option and use the set permission string to set - * the permission on the key in Gitblit. Example: - * environment="gbPerm=V",pty ssh-rsa AAAxjka.....dv= Clone only key - * Above entry would create a RSA key with the comment "Clone only key" and - * set the key permission to CLONE. All other options are ignored. - * - * In Active Directory SSH public keys are sometimes stored in the attribute - * 'altSecurityIdentity'. The attribute value is usually prefixed by a type - * identifier. LDAP entries could have the following attribute values: - * altSecurityIdentity: X.509: ADKEJBAKDBZUPABBD... - * altSecurityIdentity: SshKey: ssh-dsa AAAAknenazuzucbhda... - * This key manager supports this by allowing an optional prefix to identify - * SSH keys. The prefix to be used should be set in the 'realm.ldap.sshPublicKey' - * setting by separating it from the attribute name with a colon, e.g.: - * realm.ldap.sshPublicKey = altSecurityIdentity:SshKey - * - * @author Florian Zschocke - * - */ public class LdapKeyManager extends IPublicKeyManager { - /** - * Pattern to find prefixes like 'SSHKey:' in key entries. - * These prefixes describe the type of an altSecurityIdentity. - * The pattern accepts anything but quote and colon up to the - * first colon at the start of a string. - */ - private static final Pattern PREFIX_PATTERN = Pattern.compile("^([^\":]+):"); - /** - * Pattern to find the string describing Gitblit permissions for a SSH key. - * The pattern matches on a string starting with 'gbPerm', matched case-insensitive, - * followed by '=' with optional whitespace around it, followed by a string of - * upper and lower case letters and '+' and '-' for the permission, which can optionally - * be enclosed in '"' or '\"' (only the leading quote is matched in the pattern). - * Only the group describing the permission is a capturing group. - */ - private static final Pattern GB_PERM_PATTERN = Pattern.compile("(?i:gbPerm)\\s*=\\s*(?:\\\\\"|\")?\\s*([A-Za-z+-]+)"); - - - private final IStoredSettings settings; - - - - @Inject - public LdapKeyManager(IStoredSettings settings) { - this.settings = settings; - } - - - @Override - public String toString() { - return getClass().getSimpleName(); - } - - @Override - public LdapKeyManager start() { - log.info(toString()); - return this; - } - - @Override - public boolean isReady() { - return true; - } - - @Override - public LdapKeyManager stop() { - return this; - } - - @Override - protected boolean isStale(String username) { - // always return true so we gets keys from LDAP every time - return true; - } - - @Override - protected List getKeysImpl(String username) { - try (LdapConnection conn = new LdapConnection(settings)) { - if (conn.connect()) { - log.info("loading ssh key for {} from LDAP directory", username); - - BindResult bindResult = conn.bind(); - if (bindResult == null) { - conn.close(); - return null; - } - - // Search the user entity - - // Support prefixing the key data, e.g. when using altSecurityIdentities in AD. - String pubKeyAttribute = settings.getString(Keys.realm.ldap.sshPublicKey, "sshPublicKey"); - String pkaPrefix = null; - int idx = pubKeyAttribute.indexOf(':'); - if (idx > 0) { - pkaPrefix = pubKeyAttribute.substring(idx +1); - pubKeyAttribute = pubKeyAttribute.substring(0, idx); - } - - SearchResult result = conn.searchUser(getSimpleUsername(username), Arrays.asList(pubKeyAttribute)); - conn.close(); - - if (result != null && result.getResultCode() == ResultCode.SUCCESS) { - if ( result.getEntryCount() > 1) { - log.info("Found more than one entry for user {} in LDAP. Cannot retrieve SSH key.", username); - return null; - } else if ( result.getEntryCount() < 1) { - log.info("Found no entry for user {} in LDAP. Cannot retrieve SSH key.", username); - return null; - } - - // Retrieve the SSH key attributes - SearchResultEntry foundUser = result.getSearchEntries().get(0); - String[] attrs = foundUser.getAttributeValues(pubKeyAttribute); - if (attrs == null ||attrs.length == 0) { - log.info("found no keys for user {} under attribute {} in directory", username, pubKeyAttribute); - return null; - } - - - // Filter resulting list to match with required special prefix in entry - List authorizedKeys = new ArrayList<>(attrs.length); - Matcher m = PREFIX_PATTERN.matcher(""); - for (int i = 0; i < attrs.length; ++i) { - // strip out line breaks - String keyEntry = Joiner.on("").join(attrs[i].replace("\r\n", "\n").split("\n")); - m.reset(keyEntry); - try { - if (m.lookingAt()) { // Key is prefixed in LDAP - if (pkaPrefix == null) { - continue; - } - String prefix = m.group(1).trim(); - if (! pkaPrefix.equalsIgnoreCase(prefix)) { - continue; - } - String s = keyEntry.substring(m.end()); // Strip prefix off - authorizedKeys.add(GbAuthorizedKeyEntry.parseAuthorizedKeyEntry(s)); - - } else { // Key is not prefixed in LDAP - if (pkaPrefix != null) { - continue; - } - String s = keyEntry; // Strip prefix off - authorizedKeys.add(GbAuthorizedKeyEntry.parseAuthorizedKeyEntry(s)); - } - } catch (IllegalArgumentException e) { - log.info("Failed to parse key entry={}:", keyEntry, e.getMessage()); - } - } - - List keyList = new ArrayList<>(authorizedKeys.size()); - for (GbAuthorizedKeyEntry keyEntry : authorizedKeys) { - try { - SshKey key = new SshKey(keyEntry.resolvePublicKey(null)); - key.setComment(keyEntry.getComment()); - setKeyPermissions(key, keyEntry); - keyList.add(key); - } catch (GeneralSecurityException | IOException e) { - log.warn("Error resolving key entry for user {}. Entry={}", username, keyEntry, e); - } - } - return keyList; - } - } - } - - return null; - } - - - @Override - public boolean addKey(String username, SshKey key) { - return false; - } - - @Override - public boolean removeKey(String username, SshKey key) { - return false; - } - - @Override - public boolean removeAllKeys(String username) { - return false; - } - - - public boolean supportsWritingKeys(UserModel user) { - return false; - } - - public boolean supportsCommentChanges(UserModel user) { - return false; - } - - public boolean supportsPermissionChanges(UserModel user) { - return false; - } - - - private void setKeyPermissions(SshKey key, GbAuthorizedKeyEntry keyEntry) { - List env = keyEntry.getLoginOptionValues("environment"); - if (env != null && !env.isEmpty()) { - // Walk over all entries and find one that sets 'gbPerm'. The last one wins. - for (String envi : env) { - Matcher m = GB_PERM_PATTERN.matcher(envi); - if (m.find()) { - String perm = m.group(1).trim(); - AccessPermission ap = AccessPermission.fromCode(perm); - if (ap == AccessPermission.NONE) { - ap = AccessPermission.valueOf(perm.toUpperCase()); - } - - if (ap != null && ap != AccessPermission.NONE) { - try { - key.setPermission(ap); - } catch (IllegalArgumentException e) { - log.warn("Incorrect permissions ({}) set for SSH key entry {}.", ap, envi, e); - } - } - } - } - } - } - - - /** - * Returns a simple username without any domain prefixes. - * - * @param username - * @return a simple username - */ - private String getSimpleUsername(String username) { - int lastSlash = username.lastIndexOf('\\'); - if (lastSlash > -1) { - username = username.substring(lastSlash + 1); - } - - return username; - } - - - /** - * Extension of the AuthorizedKeyEntry from Mina SSHD with better option parsing. - * - * The class makes use of code from the two methods copied from the original - * Mina SSHD AuthorizedKeyEntry class. The code is rewritten to improve user login - * option support. Options are correctly parsed even if they have whitespace within - * double quotes. Options can occur multiple times, which is needed for example for - * the "environment" option. Thus for an option a list of strings is kept, holding - * multiple option values. - */ - private static class GbAuthorizedKeyEntry extends AuthorizedKeyEntry { - - private static final long serialVersionUID = 1L; - /** - * Pattern to extract the first part of the key entry without whitespace or only with quoted whitespace. - * The pattern essentially splits the line in two parts with two capturing groups. All other groups - * in the pattern are non-capturing. The first part is a continuous string that only includes double quoted - * whitespace and ends in whitespace. The second part is the rest of the line. - * The first part is at the beginning of the line, the lead-in. For a SSH key entry this can either be - * login options (see authorized keys file description) or the key type. Since options, other than the - * key type, can include whitespace and escaped double quotes within double quotes, the pattern takes - * care of that by searching for either "characters that are not whitespace and not double quotes" - * or "a double quote, followed by 'characters that are not a double quote or backslash, or a backslash - * and then a double quote, or a backslash', followed by a double quote". - */ - private static final Pattern LEADIN_PATTERN = Pattern.compile("^((?:[^\\s\"]*|(?:\"(?:[^\"\\\\]|\\\\\"|\\\\)*\"))*\\s+)(.+)"); - /** - * Pattern to split a comma separated list of options. - * Since an option could contain commas (as well as escaped double quotes) within double quotes - * in the option value, a simple split on comma is not enough. So the pattern searches for multiple - * occurrences of: - * characters that are not double quotes or a comma, or - * a double quote followed by: characters that are not a double quote or backslash, or - * a backslash and then a double quote, or - * a backslash, - * followed by a double quote. - */ - private static final Pattern OPTION_PATTERN = Pattern.compile("([^\",]+|(?:\"(?:[^\"\\\\]|\\\\\"|\\\\)*\"))+"); - - // for options that have no value, "true" is used - private Map> loginOptionsMulti = Collections.emptyMap(); - - - List getLoginOptionValues(String option) { - return loginOptionsMulti.get(option); - } - - - - /** - * @param line Original line from an authorized_keys file - * @return {@link GbAuthorizedKeyEntry} or {@code null} if the line is - * {@code null}/empty or a comment line - * @throws IllegalArgumentException If failed to parse/decode the line - * @see #COMMENT_CHAR - */ - public static GbAuthorizedKeyEntry parseAuthorizedKeyEntry(String line) throws IllegalArgumentException { - line = GenericUtils.trimToEmpty(line); - if (StringUtils.isEmpty(line) || (line.charAt(0) == COMMENT_CHAR) /* comment ? */) { - return null; - } - - Matcher m = LEADIN_PATTERN.matcher(line); - if (! m.lookingAt()) { - throw new IllegalArgumentException("Bad format (no key data delimiter): " + line); - } - - String keyType = m.group(1).trim(); - final GbAuthorizedKeyEntry entry; - if (KeyUtils.getPublicKeyEntryDecoder(keyType) == null) { // assume this is due to the fact that it starts with login options - entry = parseAuthorizedKeyEntry(m.group(2)); - if (entry == null) { - throw new IllegalArgumentException("Bad format (no key data after login options): " + line); - } - - entry.parseAndSetLoginOptions(keyType); - } else { - int startPos = line.indexOf(' '); - if (startPos <= 0) { - throw new IllegalArgumentException("Bad format (no key data delimiter): " + line); - } - - int endPos = line.indexOf(' ', startPos + 1); - if (endPos <= startPos) { - endPos = line.length(); - } - - String encData = (endPos < (line.length() - 1)) ? line.substring(0, endPos).trim() : line; - String comment = (endPos < (line.length() - 1)) ? line.substring(endPos + 1).trim() : null; - entry = parsePublicKeyEntry(new GbAuthorizedKeyEntry(), encData); - entry.setComment(comment); - } - - return entry; - } - - private void parseAndSetLoginOptions(String options) { - Matcher m = OPTION_PATTERN.matcher(options); - if (! m.find()) { - loginOptionsMulti = Collections.emptyMap(); - } - Map> optsMap = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); - - do { - String p = m.group(); - p = GenericUtils.trimToEmpty(p); - if (StringUtils.isEmpty(p)) { - continue; - } - - int pos = p.indexOf('='); - String name = (pos < 0) ? p : GenericUtils.trimToEmpty(p.substring(0, pos)); - CharSequence value = (pos < 0) ? null : GenericUtils.trimToEmpty(p.substring(pos + 1)); - value = GenericUtils.stripQuotes(value); - - // For options without value the value is set to TRUE. - if (value == null) { - value = Boolean.TRUE.toString(); - } - - List opts = optsMap.get(name); - if (opts == null) { - opts = new ArrayList(); - optsMap.put(name, opts); - } - opts.add(value.toString()); - } while(m.find()); - - loginOptionsMulti = optsMap; - } - } - + private static final Pattern PREFIX_PATTERN = Pattern.compile("^([^\":]+):"); + private static final Pattern GB_PERM_PATTERN = Pattern.compile("(?i:gbPerm)\\s*=\\s*(?:\\\\\"|\")?\\s*([A-Za-z+-]+)"); + + private final IStoredSettings settings; + + @Inject + public LdapKeyManager(IStoredSettings settings) { + this.settings = settings; + } + + @Override + public String toString() { + return getClass().getSimpleName(); + } + + @Override + public LdapKeyManager start() { + log.info(toString()); + return this; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public LdapKeyManager stop() { + return this; + } + + @Override + protected boolean isStale(String username) { + return true; + } + + @Override + protected List getKeysImpl(String username) { + try (LdapConnection conn = new LdapConnection(settings)) { + if (conn.connect()) { + log.info("loading ssh key for {} from LDAP directory", username); + + BindResult bindResult = conn.bind(); + if (bindResult == null) { + conn.close(); + return null; + } + + String pubKeyAttribute = settings.getString(Keys.realm.ldap.sshPublicKey, "sshPublicKey"); + String pkaPrefix = null; + int idx = pubKeyAttribute.indexOf(':'); + if (idx > 0) { + pkaPrefix = pubKeyAttribute.substring(idx + 1); + pubKeyAttribute = pubKeyAttribute.substring(0, idx); + } + + SearchResult result = conn.searchUser(getSimpleUsername(username), Arrays.asList(pubKeyAttribute)); + conn.close(); + + if (result != null && result.getResultCode() == ResultCode.SUCCESS) { + if (result.getEntryCount() > 1) { + log.info("Found more than one entry for user {} in LDAP. Cannot retrieve SSH key.", username); + return null; + } else if (result.getEntryCount() < 1) { + log.info("Found no entry for user {} in LDAP. Cannot retrieve SSH key.", username); + return null; + } + + SearchResultEntry foundUser = result.getSearchEntries().get(0); + String[] attrs = foundUser.getAttributeValues(pubKeyAttribute); + if (attrs == null || attrs.length == 0) { + log.info("found no keys for user {} under attribute {} in directory", username, pubKeyAttribute); + return null; + } + + List authorizedKeys = new ArrayList(attrs.length); + Matcher m = PREFIX_PATTERN.matcher(""); + for (int i = 0; i < attrs.length; ++i) { + String keyEntry = Joiner.on("").join(attrs[i].replace("\r\n", "\n").split("\n")); + m.reset(keyEntry); + try { + if (m.lookingAt()) { + if (pkaPrefix == null) { + continue; + } + String prefix = m.group(1).trim(); + if (!pkaPrefix.equalsIgnoreCase(prefix)) { + continue; + } + authorizedKeys.add(GbAuthorizedKeyEntry.parseAuthorizedKeyEntry(keyEntry.substring(m.end()))); + } else { + if (pkaPrefix != null) { + continue; + } + authorizedKeys.add(GbAuthorizedKeyEntry.parseAuthorizedKeyEntry(keyEntry)); + } + } catch (IllegalArgumentException e) { + log.info("Failed to parse key entry={}:", keyEntry, e.getMessage()); + } + } + + List keyList = new ArrayList(authorizedKeys.size()); + for (GbAuthorizedKeyEntry keyEntry : authorizedKeys) { + try { + SshKey key = new SshKey(keyEntry.resolvePublicKey(null, null)); + key.setComment(keyEntry.getComment()); + setKeyPermissions(key, keyEntry); + keyList.add(key); + } catch (GeneralSecurityException | IOException e) { + log.warn("Error resolving key entry for user {}. Entry={}", username, keyEntry, e); + } + } + return keyList; + } + } + } + + return null; + } + + @Override + public boolean addKey(String username, SshKey key) { + return false; + } + + @Override + public boolean removeKey(String username, SshKey key) { + return false; + } + + @Override + public boolean removeAllKeys(String username) { + return false; + } + + public boolean supportsWritingKeys(UserModel user) { + return false; + } + + public boolean supportsCommentChanges(UserModel user) { + return false; + } + + public boolean supportsPermissionChanges(UserModel user) { + return false; + } + + private void setKeyPermissions(SshKey key, GbAuthorizedKeyEntry keyEntry) { + List env = keyEntry.getLoginOptionValues("environment"); + if (env != null && !env.isEmpty()) { + for (String envi : env) { + Matcher m = GB_PERM_PATTERN.matcher(envi); + if (m.find()) { + String perm = m.group(1).trim(); + AccessPermission ap = AccessPermission.fromCode(perm); + if (ap == AccessPermission.NONE) { + ap = AccessPermission.valueOf(perm.toUpperCase()); + } + + if (ap != null && ap != AccessPermission.NONE) { + try { + key.setPermission(ap); + } catch (IllegalArgumentException e) { + log.warn("Incorrect permissions ({}) set for SSH key entry {}.", ap, envi, e); + } + } + } + } + } + } + + private String getSimpleUsername(String username) { + int lastSlash = username.lastIndexOf('\\'); + if (lastSlash > -1) { + username = username.substring(lastSlash + 1); + } + + return username; + } + + private static class GbAuthorizedKeyEntry extends AuthorizedKeyEntry { + + private static final long serialVersionUID = 1L; + private static final Pattern LEADIN_PATTERN = Pattern.compile("^((?:[^\\s\"]*|(?:\"(?:[^\"\\\\]|\\\\\"|\\\\)*\"))*\\s+)(.+)"); + private static final Pattern OPTION_PATTERN = Pattern.compile("([^\",]+|(?:\"(?:[^\"\\\\]|\\\\\"|\\\\)*\"))+"); + + private Map> loginOptionsMulti = Collections.emptyMap(); + + List getLoginOptionValues(String option) { + return loginOptionsMulti.get(option); + } + + public static GbAuthorizedKeyEntry parseAuthorizedKeyEntry(String line) throws IllegalArgumentException { + line = GenericUtils.trimToEmpty(line); + if (StringUtils.isEmpty(line) || (line.charAt(0) == COMMENT_CHAR)) { + return null; + } + + Matcher m = LEADIN_PATTERN.matcher(line); + if (!m.lookingAt()) { + throw new IllegalArgumentException("Bad format (no key data delimiter): " + line); + } + + String keyType = m.group(1).trim(); + final GbAuthorizedKeyEntry entry; + if (KeyUtils.getPublicKeyEntryDecoder(keyType) == null) { + entry = parseAuthorizedKeyEntry(m.group(2)); + if (entry == null) { + throw new IllegalArgumentException("Bad format (no key data after login options): " + line); + } + + entry.parseAndSetLoginOptions(keyType); + } else { + int startPos = line.indexOf(' '); + if (startPos <= 0) { + throw new IllegalArgumentException("Bad format (no key data delimiter): " + line); + } + + int endPos = line.indexOf(' ', startPos + 1); + if (endPos <= startPos) { + endPos = line.length(); + } + + String encData = (endPos < (line.length() - 1)) ? line.substring(0, endPos).trim() : line; + String comment = (endPos < (line.length() - 1)) ? line.substring(endPos + 1).trim() : null; + entry = parsePublicKeyEntry(new GbAuthorizedKeyEntry(), encData); + entry.setComment(comment); + } + + return entry; + } + + private void parseAndSetLoginOptions(String options) { + Matcher m = OPTION_PATTERN.matcher(options); + if (!m.find()) { + loginOptionsMulti = Collections.emptyMap(); + return; + } + Map> optsMap = new TreeMap>(String.CASE_INSENSITIVE_ORDER); + + do { + String p = m.group(); + p = GenericUtils.trimToEmpty(p); + if (StringUtils.isEmpty(p)) { + continue; + } + + int pos = p.indexOf('='); + String name = (pos < 0) ? p : GenericUtils.trimToEmpty(p.substring(0, pos)); + CharSequence value = (pos < 0) ? null : GenericUtils.trimToEmpty(p.substring(pos + 1)); + value = GenericUtils.stripQuotes(value); + + if (value == null) { + value = Boolean.TRUE.toString(); + } + + List opts = optsMap.get(name); + if (opts == null) { + opts = new ArrayList(); + optsMap.put(name, opts); + } + opts.add(value.toString()); + } while (m.find()); + + loginOptionsMulti = optsMap; + } + } } diff --git a/src/main/java/com/gitblit/transport/ssh/ServerSigAlgsExtensionHandler.java b/src/main/java/com/gitblit/transport/ssh/ServerSigAlgsExtensionHandler.java new file mode 100644 index 000000000..a14c4a19a --- /dev/null +++ b/src/main/java/com/gitblit/transport/ssh/ServerSigAlgsExtensionHandler.java @@ -0,0 +1,118 @@ +/* + * Copyright 2026 gitblit.com. + * + * 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 com.gitblit.transport.ssh; + +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.BiConsumer; + +import org.apache.sshd.common.AttributeRepository.AttributeKey; +import org.apache.sshd.common.kex.KexProposalOption; +import org.apache.sshd.common.kex.extension.KexExtensionHandler; +import org.apache.sshd.common.kex.extension.KexExtensions; +import org.apache.sshd.common.kex.extension.parser.ServerSignatureAlgorithms; +import org.apache.sshd.common.session.Session; +import org.apache.sshd.common.util.GenericUtils; +import org.apache.sshd.common.util.buffer.Buffer; +import org.apache.sshd.common.util.logging.AbstractLoggingBean; + +public class ServerSigAlgsExtensionHandler extends AbstractLoggingBean implements KexExtensionHandler { + + public static final AttributeKey CLIENT_REQUESTED_EXT_INFO = new AttributeKey<>(); + public static final AttributeKey EXT_INFO_SENT_AT_NEWKEYS = new AttributeKey<>(); + + @Override + public void handleKexInitProposal(Session session, boolean initiator, Map proposal) + throws Exception { + if (initiator) { + return; + } + + if (session.getAttribute(CLIENT_REQUESTED_EXT_INFO) != null) { + return; + } + + String algorithms = proposal.get(KexProposalOption.ALGORITHMS); + boolean clientWantsExtInfo = Arrays.asList(GenericUtils.split(algorithms, ',')) + .contains(KexExtensions.CLIENT_KEX_EXTENSION); + session.setAttribute(CLIENT_REQUESTED_EXT_INFO, Boolean.valueOf(clientWantsExtInfo)); + if (clientWantsExtInfo && log.isTraceEnabled()) { + log.trace("handleKexInitProposal({}): got ext-info-c from client", session); + } + } + + @Override + public boolean isKexExtensionsAvailable(Session session, AvailabilityPhase phase) { + return phase != AvailabilityPhase.PREKEX; + } + + @Override + public void sendKexExtensions(Session session, KexPhase phase) throws Exception { + if (phase == KexPhase.NEWKEYS) { + Boolean alreadySent = session.getAttribute(EXT_INFO_SENT_AT_NEWKEYS); + if (Boolean.TRUE.equals(alreadySent)) { + return; + } + session.setAttribute(EXT_INFO_SENT_AT_NEWKEYS, Boolean.TRUE); + } + + Boolean doExtInfo = session.getAttribute(CLIENT_REQUESTED_EXT_INFO); + if (!Boolean.TRUE.equals(doExtInfo)) { + if (log.isTraceEnabled()) { + log.trace("sendKexExtensions({})[{}]: client did not send ext-info-c; skipping SSH_MSG_EXT_INFO", + session, phase); + } + return; + } + + Map extensions = new LinkedHashMap<>(); + collectExtensions(session, phase, extensions::put); + if (extensions.isEmpty()) { + if (log.isDebugEnabled()) { + log.debug("sendKexExtensions({})[{}]: no extension info; skipping SSH_MSG_EXT_INFO", session, phase); + } + return; + } + + Buffer buffer = session.createBuffer(KexExtensions.SSH_MSG_EXT_INFO); + KexExtensions.putExtensions(extensions.entrySet(), buffer); + session.writePacket(buffer); + if (log.isDebugEnabled()) { + log.debug("sendKexExtensions({})[{}]: sending SSH_MSG_EXT_INFO with {} info records", + session, phase, Integer.valueOf(extensions.size())); + } + } + + public void collectExtensions(Session session, KexPhase phase, BiConsumer marshaller) { + if (phase != KexPhase.NEWKEYS) { + return; + } + + Collection algorithms = session.getSignatureFactoriesNames(); + if (!GenericUtils.isEmpty(algorithms)) { + marshaller.accept(ServerSignatureAlgorithms.NAME, algorithms); + if (log.isDebugEnabled()) { + log.debug("collectExtensions({})[{}]: extension info {}: {}", session, phase, + ServerSignatureAlgorithms.NAME, String.join(",", algorithms)); + } + } else if (log.isWarnEnabled()) { + log.warn("collectExtensions({})[{}]: extension info {} has no algorithms; skipping", + session, phase, ServerSignatureAlgorithms.NAME); + } + } +} diff --git a/src/main/java/com/gitblit/transport/ssh/SshDaemon.java b/src/main/java/com/gitblit/transport/ssh/SshDaemon.java index d4f1fab01..9b14edf7c 100644 --- a/src/main/java/com/gitblit/transport/ssh/SshDaemon.java +++ b/src/main/java/com/gitblit/transport/ssh/SshDaemon.java @@ -15,7 +15,6 @@ */ package com.gitblit.transport.ssh; -import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; @@ -29,16 +28,17 @@ import java.util.concurrent.atomic.AtomicBoolean; import net.i2p.crypto.eddsa.EdDSAPrivateKey; +import net.i2p.crypto.eddsa.EdDSAPublicKey; import org.apache.sshd.common.config.keys.KeyEntryResolver; import org.apache.sshd.common.io.IoServiceFactoryFactory; -import org.apache.sshd.common.io.mina.MinaServiceFactoryFactory; import org.apache.sshd.common.io.nio2.Nio2ServiceFactoryFactory; +import org.apache.sshd.core.CoreModuleProperties; +import org.apache.sshd.common.util.io.output.SecureByteArrayOutputStream; import org.apache.sshd.common.util.security.SecurityUtils; import org.apache.sshd.common.util.security.bouncycastle.BouncyCastleSecurityProviderRegistrar; import org.apache.sshd.common.util.security.eddsa.EdDSASecurityProviderRegistrar; import org.apache.sshd.common.util.security.eddsa.OpenSSHEd25519PrivateKeyEntryDecoder; import org.apache.sshd.server.SshServer; -import org.apache.sshd.server.auth.pubkey.CachingPublicKeyAuthenticator; import org.bouncycastle.crypto.params.AsymmetricKeyParameter; import org.bouncycastle.crypto.util.OpenSSHPrivateKeyUtil; import org.bouncycastle.crypto.util.PrivateKeyFactory; @@ -59,264 +59,216 @@ import com.gitblit.utils.WorkQueue; import com.google.common.io.Files; -/** - * Manager for the ssh transport. Roughly analogous to the - * {@link com.gitblit.transport.git.GitDaemon} class. - * - */ public class SshDaemon { - private static final Logger log = LoggerFactory.getLogger(SshDaemon.class); - - private static final String AUTH_PUBLICKEY = "publickey"; - private static final String AUTH_PASSWORD = "password"; - private static final String AUTH_KBD_INTERACTIVE = "keyboard-interactive"; - private static final String AUTH_GSSAPI = "gssapi-with-mic"; - - - - public static enum SshSessionBackend { - MINA, NIO2 - } - - /** - * 22: IANA assigned port number for ssh. Note that this is a distinct - * concept from gitblit's default conf for ssh port -- this "default" is - * what the git protocol itself defaults to if it sees and ssh url without a - * port. - */ - public static final int DEFAULT_PORT = 22; - - private final AtomicBoolean run; + private static final Logger log = LoggerFactory.getLogger(SshDaemon.class); - private final IGitblit gitblit; - private final SshServer sshd; + private static final String AUTH_PUBLICKEY = "publickey"; + private static final String AUTH_PASSWORD = "password"; + private static final String AUTH_KBD_INTERACTIVE = "keyboard-interactive"; + private static final String AUTH_GSSAPI = "gssapi-with-mic"; - /** - * Construct the Gitblit SSH daemon. - * - * @param gitblit - * @param workQueue - */ - public SshDaemon(IGitblit gitblit, WorkQueue workQueue) { - this.gitblit = gitblit; - - IStoredSettings settings = gitblit.getSettings(); + public static enum SshSessionBackend { + MINA, NIO2 + } - // Ensure that Bouncy Castle is our JCE provider - SecurityUtils.registerSecurityProvider(new BouncyCastleSecurityProviderRegistrar()); - if (SecurityUtils.isBouncyCastleRegistered()) { - log.info("BouncyCastle is registered as a JCE provider"); - } - // Add support for ED25519_SHA512 - SecurityUtils.registerSecurityProvider(new EdDSASecurityProviderRegistrar()); - if (SecurityUtils.isProviderRegistered("EdDSA")) { - log.info("EdDSA is registered as a JCE provider"); - } + public static final int DEFAULT_PORT = 22; - // Generate host RSA and DSA keypairs and create the host keypair provider - File rsaKeyStore = new File(gitblit.getBaseFolder(), "ssh-rsa-hostkey.pem"); - File dsaKeyStore = new File(gitblit.getBaseFolder(), "ssh-dsa-hostkey.pem"); - File ecdsaKeyStore = new File(gitblit.getBaseFolder(), "ssh-ecdsa-hostkey.pem"); - File eddsaKeyStore = new File(gitblit.getBaseFolder(), "ssh-eddsa-hostkey.pem"); - File ed25519KeyStore = new File(gitblit.getBaseFolder(), "ssh-ed25519-hostkey.pem"); - generateKeyPair(rsaKeyStore, "RSA", 2048); - generateKeyPair(ecdsaKeyStore, "ECDSA", 256); - generateKeyPair(eddsaKeyStore, "EdDSA", 0); - FileKeyPairProvider hostKeyPairProvider = new FileKeyPairProvider(); - hostKeyPairProvider.setFiles(new String [] { ecdsaKeyStore.getPath(), eddsaKeyStore.getPath(), ed25519KeyStore.getPath(), rsaKeyStore.getPath(), dsaKeyStore.getPath() }); + private final AtomicBoolean run; + private final IGitblit gitblit; + private final SshServer sshd; + public SshDaemon(IGitblit gitblit, WorkQueue workQueue) { + this.gitblit = gitblit; - // Configure the preferred SSHD backend - String sshBackendStr = settings.getString(Keys.git.sshBackend, - SshSessionBackend.NIO2.name()); - SshSessionBackend backend = SshSessionBackend.valueOf(sshBackendStr); - System.setProperty(IoServiceFactoryFactory.class.getName(), - backend == SshSessionBackend.MINA - ? MinaServiceFactoryFactory.class.getName() - : Nio2ServiceFactoryFactory.class.getName()); + IStoredSettings settings = gitblit.getSettings(); - // Create the socket address for binding the SSH server - int port = settings.getInteger(Keys.git.sshPort, 0); - String bindInterface = settings.getString(Keys.git.sshBindInterface, ""); - InetSocketAddress addr; - if (StringUtils.isEmpty(bindInterface)) { - addr = new InetSocketAddress(port); - } else { - addr = new InetSocketAddress(bindInterface, port); - } + SecurityUtils.registerSecurityProvider(new BouncyCastleSecurityProviderRegistrar()); + if (SecurityUtils.isBouncyCastleRegistered()) { + log.info("BouncyCastle is registered as a JCE provider"); + } + SecurityUtils.registerSecurityProvider(new EdDSASecurityProviderRegistrar()); + if (SecurityUtils.isProviderRegistered("EdDSA")) { + log.info("EdDSA is registered as a JCE provider"); + } - // Create the SSH server - sshd = SshServer.setUpDefaultServer(); - sshd.setPort(addr.getPort()); - sshd.setHost(addr.getHostName()); - sshd.setKeyPairProvider(hostKeyPairProvider); + File rsaKeyStore = new File(gitblit.getBaseFolder(), "ssh-rsa-hostkey.pem"); + File dsaKeyStore = new File(gitblit.getBaseFolder(), "ssh-dsa-hostkey.pem"); + File ecdsaKeyStore = new File(gitblit.getBaseFolder(), "ssh-ecdsa-hostkey.pem"); + File eddsaKeyStore = new File(gitblit.getBaseFolder(), "ssh-eddsa-hostkey.pem"); + File ed25519KeyStore = new File(gitblit.getBaseFolder(), "ssh-ed25519-hostkey.pem"); + generateKeyPair(rsaKeyStore, "RSA", 2048); + generateKeyPair(ecdsaKeyStore, "ECDSA", 256); + generateKeyPair(eddsaKeyStore, "EdDSA", 0); + FileKeyPairProvider hostKeyPairProvider = new FileKeyPairProvider(); + hostKeyPairProvider.setFiles(new String[] { + ecdsaKeyStore.getPath(), + eddsaKeyStore.getPath(), + ed25519KeyStore.getPath(), + rsaKeyStore.getPath(), + dsaKeyStore.getPath() + }); + + String sshBackendStr = settings.getString(Keys.git.sshBackend, SshSessionBackend.NIO2.name()); + SshSessionBackend backend = SshSessionBackend.valueOf(sshBackendStr); + if (backend == SshSessionBackend.MINA) { + log.warn("MINA SSH backend is no longer supported in the patched image; forcing NIO2"); + backend = SshSessionBackend.NIO2; + sshBackendStr = backend.name(); + } + System.setProperty(IoServiceFactoryFactory.class.getName(), Nio2ServiceFactoryFactory.class.getName()); + + int port = settings.getInteger(Keys.git.sshPort, 0); + String bindInterface = settings.getString(Keys.git.sshBindInterface, ""); + InetSocketAddress addr; + if (StringUtils.isEmpty(bindInterface)) { + addr = new InetSocketAddress(port); + } else { + addr = new InetSocketAddress(bindInterface, port); + } - List authMethods = settings.getStrings(Keys.git.sshAuthenticationMethods); - if (authMethods.isEmpty()) { - authMethods.add(AUTH_PUBLICKEY); - authMethods.add(AUTH_PASSWORD); - } - // Keep backward compatibility with old setting files that use the git.sshWithKrb5 setting. - if (settings.getBoolean("git.sshWithKrb5", false) && !authMethods.contains(AUTH_GSSAPI)) { - authMethods.add(AUTH_GSSAPI); - log.warn("git.sshWithKrb5 is obsolete!"); - log.warn("Please add {} to {} in gitblit.properties!", AUTH_GSSAPI, Keys.git.sshAuthenticationMethods); - settings.overrideSetting(Keys.git.sshAuthenticationMethods, - settings.getString(Keys.git.sshAuthenticationMethods, AUTH_PUBLICKEY + " " + AUTH_PASSWORD) + " " + AUTH_GSSAPI); - } - if (authMethods.contains(AUTH_PUBLICKEY)) { - SshKeyAuthenticator keyAuthenticator = new SshKeyAuthenticator(gitblit.getPublicKeyManager(), gitblit); - sshd.setPublickeyAuthenticator(new CachingPublicKeyAuthenticator(keyAuthenticator)); - log.info("SSH: adding public key authentication method."); - } - if (authMethods.contains(AUTH_PASSWORD) || authMethods.contains(AUTH_KBD_INTERACTIVE)) { - sshd.setPasswordAuthenticator(new UsernamePasswordAuthenticator(gitblit)); - log.info("SSH: adding password authentication method."); - } - if (authMethods.contains(AUTH_GSSAPI)) { - sshd.setGSSAuthenticator(new SshKrbAuthenticator(settings, gitblit)); - log.info("SSH: adding GSSAPI authentication method."); - } + sshd = SshServer.setUpDefaultServer(); + sshd.setPort(addr.getPort()); + sshd.setHost(addr.getHostName()); + sshd.setKeyPairProvider(hostKeyPairProvider); - sshd.setSessionFactory(new SshServerSessionFactory(sshd)); - sshd.setFileSystemFactory(new DisabledFilesystemFactory()); - sshd.setForwardingFilter(new NonForwardingFilter()); - sshd.setCommandFactory(new SshCommandFactory(gitblit, workQueue)); - sshd.setShellFactory(new WelcomeShell(gitblit)); + List authMethods = settings.getStrings(Keys.git.sshAuthenticationMethods); + if (authMethods.isEmpty()) { + authMethods.add(AUTH_PUBLICKEY); + authMethods.add(AUTH_PASSWORD); + } + if (settings.getBoolean("git.sshWithKrb5", false) && !authMethods.contains(AUTH_GSSAPI)) { + authMethods.add(AUTH_GSSAPI); + log.warn("git.sshWithKrb5 is obsolete!"); + log.warn("Please add {} to {} in gitblit.properties!", AUTH_GSSAPI, Keys.git.sshAuthenticationMethods); + settings.overrideSetting(Keys.git.sshAuthenticationMethods, + settings.getString(Keys.git.sshAuthenticationMethods, AUTH_PUBLICKEY + " " + AUTH_PASSWORD) + " " + AUTH_GSSAPI); + } + if (authMethods.contains(AUTH_PUBLICKEY)) { + SshKeyAuthenticator keyAuthenticator = new SshKeyAuthenticator(gitblit.getPublicKeyManager(), gitblit); + sshd.setPublickeyAuthenticator(keyAuthenticator); + log.info("SSH: adding public key authentication method."); + } + if (authMethods.contains(AUTH_PASSWORD) || authMethods.contains(AUTH_KBD_INTERACTIVE)) { + sshd.setPasswordAuthenticator(new UsernamePasswordAuthenticator(gitblit)); + log.info("SSH: adding password authentication method."); + } + if (authMethods.contains(AUTH_GSSAPI)) { + sshd.setGSSAuthenticator(new SshKrbAuthenticator(settings, gitblit)); + log.info("SSH: adding GSSAPI authentication method."); + } - // Set the server id. This can be queried with: - // ssh-keyscan -t rsa,dsa -p 29418 localhost - String version = String.format("%s (%s-%s)", Constants.getGitBlitVersion().replace(' ', '_'), - sshd.getVersion(), sshBackendStr); - sshd.getProperties().put(SshServer.SERVER_IDENTIFICATION, version); + sshd.setSessionFactory(new SshServerSessionFactory(sshd)); + sshd.setKexExtensionHandler(new ServerSigAlgsExtensionHandler()); + sshd.setFileSystemFactory(new DisabledFilesystemFactory()); + sshd.setForwardingFilter(new NonForwardingFilter()); + sshd.setCommandFactory(new SshCommandFactory(gitblit, workQueue)); + sshd.setShellFactory(new WelcomeShell(gitblit)); - run = new AtomicBoolean(false); - } + String version = String.format("%s (%s-%s)", Constants.getGitBlitVersion().replace(' ', '_'), + sshd.getVersion(), sshBackendStr); + CoreModuleProperties.SERVER_IDENTIFICATION.set(sshd, version); - public String formatUrl(String gituser, String servername, String repository) { - IStoredSettings settings = gitblit.getSettings(); + run = new AtomicBoolean(false); + } - int port = sshd.getPort(); - int displayPort = settings.getInteger(Keys.git.sshAdvertisedPort, port); - String displayServername = settings.getString(Keys.git.sshAdvertisedHost, ""); - if(displayServername.isEmpty()) { - displayServername = servername; - } - if (displayPort == DEFAULT_PORT) { - // standard port - return MessageFormat.format("ssh://{0}@{1}/{2}", gituser, displayServername, - repository); - } else { - // non-standard port - return MessageFormat.format("ssh://{0}@{1}:{2,number,0}/{3}", - gituser, displayServername, displayPort, repository); - } - } + public String formatUrl(String gituser, String servername, String repository) { + IStoredSettings settings = gitblit.getSettings(); - /** - * Start this daemon on a background thread. - * - * @throws IOException - * the server socket could not be opened. - * @throws IllegalStateException - * the daemon is already running. - */ - public synchronized void start() throws IOException { - if (run.get()) { - throw new IllegalStateException(JGitText.get().daemonAlreadyRunning); - } + int port = sshd.getPort(); + int displayPort = settings.getInteger(Keys.git.sshAdvertisedPort, port); + String displayServername = settings.getString(Keys.git.sshAdvertisedHost, ""); + if (displayServername.isEmpty()) { + displayServername = servername; + } + if (displayPort == DEFAULT_PORT) { + return MessageFormat.format("ssh://{0}@{1}/{2}", gituser, displayServername, repository); + } + return MessageFormat.format("ssh://{0}@{1}:{2,number,0}/{3}", + gituser, displayServername, displayPort, repository); + } - sshd.start(); - run.set(true); + public synchronized void start() throws IOException { + if (run.get()) { + throw new IllegalStateException(JGitText.get().daemonAlreadyRunning); + } - String sshBackendStr = gitblit.getSettings().getString(Keys.git.sshBackend, - SshSessionBackend.NIO2.name()); + sshd.start(); + run.set(true); - log.info(MessageFormat.format( - "SSH Daemon ({0}) is listening on {1}:{2,number,0}", - sshBackendStr, sshd.getHost(), sshd.getPort())); - } + String sshBackendStr = gitblit.getSettings().getString(Keys.git.sshBackend, SshSessionBackend.NIO2.name()); + log.info(MessageFormat.format("SSH Daemon ({0}) is listening on {1}:{2,number,0}", + sshBackendStr, sshd.getHost(), sshd.getPort())); + } - /** @return true if this daemon is receiving connections. */ - public boolean isRunning() { - return run.get(); - } + public boolean isRunning() { + return run.get(); + } - /** Stop this daemon. */ - public synchronized void stop() { - if (run.get()) { - log.info("SSH Daemon stopping..."); - run.set(false); + public synchronized void stop() { + if (run.get()) { + log.info("SSH Daemon stopping..."); + run.set(false); - try { - ((SshCommandFactory) sshd.getCommandFactory()).stop(); - sshd.stop(); - } catch (IOException e) { - log.error("SSH Daemon stop interrupted", e); - } - } - } + try { + ((SshCommandFactory) sshd.getCommandFactory()).stop(); + sshd.stop(); + } catch (IOException e) { + log.error("SSH Daemon stop interrupted", e); + } + } + } static void generateKeyPair(File file, String algorithm, int keySize) { - if (file.exists()) { - return; - } + if (file.exists()) { + return; + } try { KeyPairGenerator generator = SecurityUtils.getKeyPairGenerator(algorithm); if (keySize != 0) { - generator.initialize(keySize); - log.info("Generating {}-{} SSH host keypair...", algorithm, keySize); + generator.initialize(keySize); + log.info("Generating {}-{} SSH host keypair...", algorithm, Integer.valueOf(keySize)); } else { log.info("Generating {} SSH host keypair...", algorithm); } KeyPair kp = generator.generateKeyPair(); - // create an empty file and set the permissions Files.touch(file); try { - JnaUtils.setFilemode(file, JnaUtils.S_IRUSR | JnaUtils.S_IWUSR); + JnaUtils.setFilemode(file, JnaUtils.S_IRUSR | JnaUtils.S_IWUSR); } catch (UnsatisfiedLinkError | UnsupportedOperationException e) { - // Unexpected/Unsupported OS or Architecture + // Unsupported platform-specific chmod path. } - FileOutputStream os = new FileOutputStream(file); - PemWriter w = new PemWriter(new OutputStreamWriter(os)); - if (algorithm.equals("ED25519")) { - // This generates a proper OpenSSH formatted ed25519 private key file. - // It is currently unused because the SSHD library in play doesn't work with proper keys. - // This is kept in the hope that in the future the library offers proper support. - AsymmetricKeyParameter keyParam = PrivateKeyFactory.createKey(kp.getPrivate().getEncoded()); - byte[] encKey = OpenSSHPrivateKeyUtil.encodePrivateKey(keyParam); - w.writeObject(new PemObject("OPENSSH PRIVATE KEY", encKey)); - } - else if (algorithm.equals("EdDSA")) { - // This saves the ed25519 key in a file format that the current SSHD library can work with. - // We call it EDDSA PRIVATE KEY, but that string is given by us and nothing official. - PrivateKey privateKey = kp.getPrivate(); - if (privateKey instanceof EdDSAPrivateKey) { - OpenSSHEd25519PrivateKeyEntryDecoder encoder = (OpenSSHEd25519PrivateKeyEntryDecoder)SecurityUtils.getOpenSSHEDDSAPrivateKeyEntryDecoder(); - EdDSAPrivateKey dsaPrivateKey = (EdDSAPrivateKey)privateKey; - // Jumping through some hoops here, because the decoder expects the key type as a string at the - // start, but the encoder doesn't put it in. So we have to put it in ourselves. - ByteArrayOutputStream encos = new ByteArrayOutputStream(); - String type = encoder.encodePrivateKey(encos, dsaPrivateKey); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); - KeyEntryResolver.encodeString(bos, type); - encos.writeTo(bos); - w.writeObject(new PemObject("EDDSA PRIVATE KEY", bos.toByteArray())); - } - else { - log.warn("Unable to encode EdDSA key, got key type " + privateKey.getClass().getCanonicalName()); - } - } - else { - w.writeObject(new JcaMiscPEMGenerator(kp)); - } - w.flush(); - w.close(); + try (FileOutputStream os = new FileOutputStream(file)) { + try (PemWriter w = new PemWriter(new OutputStreamWriter(os))) { + if ("ED25519".equals(algorithm)) { + AsymmetricKeyParameter keyParam = PrivateKeyFactory.createKey(kp.getPrivate().getEncoded()); + byte[] encKey = OpenSSHPrivateKeyUtil.encodePrivateKey(keyParam); + w.writeObject(new PemObject("OPENSSH PRIVATE KEY", encKey)); + } else if ("EdDSA".equals(algorithm)) { + PrivateKey privateKey = kp.getPrivate(); + if (privateKey instanceof EdDSAPrivateKey) { + OpenSSHEd25519PrivateKeyEntryDecoder encoder = + (OpenSSHEd25519PrivateKeyEntryDecoder) SecurityUtils.getOpenSSHEDDSAPrivateKeyEntryDecoder(); + EdDSAPrivateKey dsaPrivateKey = (EdDSAPrivateKey) privateKey; + EdDSAPublicKey dsaPublicKey = encoder.recoverPublicKey(dsaPrivateKey); + SecureByteArrayOutputStream encos = new SecureByteArrayOutputStream(); + String type = encoder.encodePrivateKey(encos, dsaPrivateKey, dsaPublicKey); + SecureByteArrayOutputStream bos = new SecureByteArrayOutputStream(); + KeyEntryResolver.encodeString(bos, type); + encos.writeTo(bos); + w.writeObject(new PemObject("EDDSA PRIVATE KEY", bos.toByteArray())); + } else { + log.warn("Unable to encode EdDSA key, got key type {}", privateKey.getClass().getCanonicalName()); + } + } else { + w.writeObject(new JcaMiscPEMGenerator(kp)); + } + } + } } catch (Exception e) { - log.warn(MessageFormat.format("Unable to generate {0} keypair", algorithm), e); + log.error("Unable to generate " + algorithm + " keypair", e); } } } diff --git a/src/main/java/com/gitblit/transport/ssh/SshDaemonClient.java b/src/main/java/com/gitblit/transport/ssh/SshDaemonClient.java index 7024a9a99..6bd5967bd 100644 --- a/src/main/java/com/gitblit/transport/ssh/SshDaemonClient.java +++ b/src/main/java/com/gitblit/transport/ssh/SshDaemonClient.java @@ -17,58 +17,68 @@ import java.net.SocketAddress; -import org.apache.sshd.common.AttributeStore.AttributeKey; +import org.apache.sshd.common.AttributeRepository.AttributeKey; +import org.apache.sshd.server.session.ServerSession; import com.gitblit.models.UserModel; -/** - * - * @author Eric Myrhe - * - */ public class SshDaemonClient { - public static final AttributeKey KEY = new AttributeKey(); - - private final SocketAddress remoteAddress; - - private volatile UserModel user; - private volatile SshKey key; - private volatile String repositoryName; - - SshDaemonClient(SocketAddress peer) { - this.remoteAddress = peer; - } - - public SocketAddress getRemoteAddress() { - return remoteAddress; - } - - public UserModel getUser() { - return user; - } - - public void setUser(UserModel user) { - this.user = user; - } - - public String getUsername() { - return user == null ? null : user.username; - } - - public void setRepositoryName(String repositoryName) { - this.repositoryName = repositoryName; - } - - public String getRepositoryName() { - return repositoryName; - } - - public SshKey getKey() { - return key; - } - - public void setKey(SshKey key) { - this.key = key; - } - + public static final AttributeKey KEY = new AttributeKey(); + + private final SocketAddress remoteAddress; + + private volatile UserModel user; + private volatile SshKey key; + private volatile String repositoryName; + + SshDaemonClient(SocketAddress peer) { + this.remoteAddress = peer; + } + + public static SshDaemonClient get(ServerSession session) { + SshDaemonClient client = session.getAttribute(KEY); + if (client != null) { + return client; + } + synchronized (session) { + client = session.getAttribute(KEY); + if (client == null) { + client = new SshDaemonClient(session.getIoSession().getRemoteAddress()); + session.setAttribute(KEY, client); + } + return client; + } + } + + public SocketAddress getRemoteAddress() { + return remoteAddress; + } + + public UserModel getUser() { + return user; + } + + public void setUser(UserModel user) { + this.user = user; + } + + public String getUsername() { + return user == null ? null : user.username; + } + + public void setRepositoryName(String repositoryName) { + this.repositoryName = repositoryName; + } + + public String getRepositoryName() { + return repositoryName; + } + + public SshKey getKey() { + return key; + } + + public void setKey(SshKey key) { + this.key = key; + } } diff --git a/src/main/java/com/gitblit/transport/ssh/SshKeyAuthenticator.java b/src/main/java/com/gitblit/transport/ssh/SshKeyAuthenticator.java index dc9d8a4e5..493962db5 100644 --- a/src/main/java/com/gitblit/transport/ssh/SshKeyAuthenticator.java +++ b/src/main/java/com/gitblit/transport/ssh/SshKeyAuthenticator.java @@ -1,5 +1,5 @@ /* - * Copyright 2014 gitblit.com. + * Copyright 2026 gitblit.com. * * 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 @@ -19,6 +19,7 @@ import java.util.List; import java.util.Locale; +import org.apache.sshd.common.config.keys.KeyUtils; import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator; import org.apache.sshd.server.session.ServerSession; import org.slf4j.Logger; @@ -26,52 +27,59 @@ import com.gitblit.manager.IAuthenticationManager; import com.gitblit.models.UserModel; -import com.google.common.base.Preconditions; -/** - * Authenticates an SSH session against a public key. - * - */ public class SshKeyAuthenticator implements PublickeyAuthenticator { - protected final Logger log = LoggerFactory.getLogger(getClass()); + protected final Logger log = LoggerFactory.getLogger(getClass()); - protected final IPublicKeyManager keyManager; + protected final IPublicKeyManager keyManager; - protected final IAuthenticationManager authManager; + protected final IAuthenticationManager authManager; - public SshKeyAuthenticator(IPublicKeyManager keyManager, IAuthenticationManager authManager) { - this.keyManager = keyManager; - this.authManager = authManager; - } + public SshKeyAuthenticator(IPublicKeyManager keyManager, IAuthenticationManager authManager) { + this.keyManager = keyManager; + this.authManager = authManager; + } - @Override - public boolean authenticate(String username, PublicKey suppliedKey, ServerSession session) { - SshDaemonClient client = session.getAttribute(SshDaemonClient.KEY); - Preconditions.checkState(client.getUser() == null); - username = username.toLowerCase(Locale.US); - List keys = keyManager.getKeys(username); - if (keys.isEmpty()) { - log.info("{} has not added any public keys for ssh authentication", username); - return false; - } + @Override + public boolean authenticate(String username, PublicKey suppliedKey, ServerSession session) { + SshDaemonClient client = SshDaemonClient.get(session); + username = username.toLowerCase(Locale.US); + if (client.getUser() != null) { + if (username.equals(client.getUsername()) && client.getKey() != null + && KeyUtils.compareKeys(client.getKey().getPublicKey(), suppliedKey)) { + log.info("ssh publickey auth reused for {}", username); + return true; + } + log.warn("ssh publickey auth state mismatch for {}", username); + return false; + } + log.info("ssh publickey auth attempt for {}", username); + List keys = keyManager.getKeys(username); + if (keys.isEmpty()) { + log.info("{} has not added any public keys for ssh authentication", username); + return false; + } - SshKey pk = new SshKey(suppliedKey); - log.debug("auth supplied {}", pk.getFingerprint()); + SshKey pk = new SshKey(suppliedKey); + log.debug("auth supplied {}", pk.getFingerprint()); - for (SshKey key : keys) { - log.debug("auth compare to {}", key.getFingerprint()); - if (key.getPublicKey().equals(suppliedKey)) { - UserModel user = authManager.authenticate(username, key); - if (user != null) { - client.setUser(user); - client.setKey(key); - return true; - } - } - } + for (SshKey key : keys) { + log.debug("auth compare to {}", key.getFingerprint()); + if (KeyUtils.compareKeys(key.getPublicKey(), suppliedKey)) { + log.info("ssh publickey auth key match for {}", username); + UserModel user = authManager.authenticate(username, key); + if (user != null) { + client.setUser(user); + client.setKey(key); + log.info("ssh publickey auth success for {}", username); + return true; + } + log.warn("ssh publickey auth user validation failed for {}", username); + } + } - log.warn("could not authenticate {} for SSH using the supplied public key", username); - return false; - } + log.warn("could not authenticate {} for SSH using the supplied public key", username); + return false; + } } diff --git a/src/main/java/com/gitblit/transport/ssh/SshServerSessionFactory.java b/src/main/java/com/gitblit/transport/ssh/SshServerSessionFactory.java index fb85781ab..d26b018f5 100644 --- a/src/main/java/com/gitblit/transport/ssh/SshServerSessionFactory.java +++ b/src/main/java/com/gitblit/transport/ssh/SshServerSessionFactory.java @@ -17,58 +17,43 @@ import java.net.SocketAddress; -import org.apache.mina.transport.socket.SocketSessionConfig; import org.apache.sshd.common.future.CloseFuture; import org.apache.sshd.common.future.SshFutureListener; import org.apache.sshd.common.io.IoSession; -import org.apache.sshd.common.io.mina.MinaSession; import org.apache.sshd.server.ServerFactoryManager; import org.apache.sshd.server.session.ServerSessionImpl; import org.apache.sshd.server.session.SessionFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** - * - * @author James Moger - * - */ public class SshServerSessionFactory extends SessionFactory { - private final Logger log = LoggerFactory.getLogger(getClass()); - - public SshServerSessionFactory(ServerFactoryManager server) { - super(server); - } - - @Override - protected ServerSessionImpl createSession(final IoSession io) throws Exception { - log.info("creating ssh session from {}", io.getRemoteAddress()); - - if (io instanceof MinaSession) { - if (((MinaSession) io).getSession().getConfig() instanceof SocketSessionConfig) { - ((SocketSessionConfig) ((MinaSession) io).getSession().getConfig()).setKeepAlive(true); - } - } - - final SshServerSession session = (SshServerSession) super.createSession(io); - SocketAddress peer = io.getRemoteAddress(); - SshDaemonClient client = new SshDaemonClient(peer); - session.setAttribute(SshDaemonClient.KEY, client); - - // TODO(davido): Log a session close without authentication as a - // failure. - session.addCloseSessionListener(new SshFutureListener() { - @Override - public void operationComplete(CloseFuture future) { - log.info("closed ssh session from {}", io.getRemoteAddress()); - } - }); - return session; - } - - @Override - protected ServerSessionImpl doCreateSession(IoSession ioSession) throws Exception { - return new SshServerSession(getServer(), ioSession); - } + private final Logger log = LoggerFactory.getLogger(getClass()); + + public SshServerSessionFactory(ServerFactoryManager server) { + super(server); + } + + @Override + protected ServerSessionImpl createSession(final IoSession io) throws Exception { + log.info("creating ssh session from {}", io.getRemoteAddress()); + + final SshServerSession session = (SshServerSession) super.createSession(io); + SocketAddress peer = io.getRemoteAddress(); + SshDaemonClient client = new SshDaemonClient(peer); + session.setAttribute(SshDaemonClient.KEY, client); + + session.addCloseSessionListener(new SshFutureListener() { + @Override + public void operationComplete(CloseFuture future) { + log.info("closed ssh session from {}", io.getRemoteAddress()); + } + }); + return session; + } + + @Override + protected ServerSessionImpl doCreateSession(IoSession ioSession) throws Exception { + return new SshServerSession(getServer(), ioSession); + } } diff --git a/src/main/java/com/gitblit/transport/ssh/WelcomeShell.java b/src/main/java/com/gitblit/transport/ssh/WelcomeShell.java index 7ea0f2480..e779854ac 100644 --- a/src/main/java/com/gitblit/transport/ssh/WelcomeShell.java +++ b/src/main/java/com/gitblit/transport/ssh/WelcomeShell.java @@ -23,12 +23,13 @@ import java.net.URL; import java.text.MessageFormat; -import org.apache.sshd.common.Factory; -import org.apache.sshd.server.Command; import org.apache.sshd.server.Environment; import org.apache.sshd.server.ExitCallback; -import org.apache.sshd.server.SessionAware; +import org.apache.sshd.server.channel.ChannelSession; +import org.apache.sshd.server.command.Command; import org.apache.sshd.server.session.ServerSession; +import org.apache.sshd.server.session.ServerSessionAware; +import org.apache.sshd.server.shell.ShellFactory; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.util.SystemReader; @@ -40,191 +41,175 @@ import com.gitblit.transport.ssh.commands.SshCommandFactory; import com.gitblit.utils.StringUtils; -/** - * Class that displays a welcome message for any shell requests. - * - */ -public class WelcomeShell implements Factory { - - private final IGitblit gitblit; - - public WelcomeShell(IGitblit gitblit) { - this.gitblit = gitblit; - } - - @Override - public Command create() { - return new SendMessage(gitblit); - } - - @Override - public Command get() { - return create(); - } - - private static class SendMessage implements Command, SessionAware { - - private final IPublicKeyManager km; - private final IStoredSettings settings; - private ServerSession session; - - private InputStream in; - private OutputStream out; - private OutputStream err; - private ExitCallback exit; - - SendMessage(IGitblit gitblit) { - this.km = gitblit.getPublicKeyManager(); - this.settings = gitblit.getSettings(); - } - - @Override - public void setInputStream(final InputStream in) { - this.in = in; - } - - @Override - public void setOutputStream(final OutputStream out) { - this.out = out; - } - - @Override - public void setErrorStream(final OutputStream err) { - this.err = err; - } - - @Override - public void setExitCallback(final ExitCallback callback) { - this.exit = callback; - } - - @Override - public void setSession(final ServerSession session) { - this.session = session; - } - - @Override - public void start(final Environment env) throws IOException { - err.write(Constants.encode(getMessage())); - err.flush(); - - in.close(); - out.close(); - err.close(); - exit.onExit(127); - } - - @Override - public void destroy() { - this.session = null; - } - - String getMessage() { - SshDaemonClient client = session.getAttribute(SshDaemonClient.KEY); - UserModel user = client.getUser(); - String hostname = getHostname(); - int port = settings.getInteger(Keys.git.sshPort, 0); - boolean writeKeysIsSupported = true; - if (km != null) { - writeKeysIsSupported = km.supportsWritingKeys(user); - } - - final String b1 = StringUtils.rightPad("", 72, '═'); - final String b2 = StringUtils.rightPad("", 72, '─'); - final String nl = "\r\n"; - - StringBuilder msg = new StringBuilder(); - msg.append(nl); - msg.append(b1); - msg.append(nl); - msg.append(" "); - msg.append(com.gitblit.Constants.getGitBlitVersion()); - msg.append(nl); - msg.append(b1); - msg.append(nl); - msg.append(nl); - msg.append(" Hi "); - msg.append(user.getDisplayName()); - msg.append(", you have successfully connected over SSH."); - msg.append(nl); - msg.append(" Interactive shells are not available."); - msg.append(nl); - msg.append(nl); - msg.append(" client: "); - msg.append(session.getClientVersion()); - msg.append(nl); - msg.append(nl); - - msg.append(b2); - msg.append(nl); - msg.append(nl); - msg.append(" You may clone a repository with the following Git syntax:"); - msg.append(nl); - msg.append(nl); - - msg.append(" git clone "); - msg.append(formatUrl(hostname, port, user.username)); - msg.append(nl); - msg.append(nl); - - msg.append(b2); - msg.append(nl); - msg.append(nl); - - if (writeKeysIsSupported && client.getKey() == null) { - // user has authenticated with a password - // display add public key instructions - msg.append(" You may upload an SSH public key with the following syntax:"); - msg.append(nl); - msg.append(nl); - - msg.append(String.format(" cat ~/.ssh/id_rsa.pub | ssh -l %s -p %d %s keys add", user.username, port, hostname)); - msg.append(nl); - msg.append(nl); - - msg.append(b2); - msg.append(nl); - msg.append(nl); - } - - // display the core commands - SshCommandFactory cmdFactory = (SshCommandFactory) session.getFactoryManager().getCommandFactory(); - DispatchCommand root = cmdFactory.createRootDispatcher(client, ""); - String usage = root.usage().replace("\n", nl); - msg.append(usage); - - return msg.toString(); - } - - private String getHostname() { - String host = null; - String url = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443"); - if (url != null) { - try { - host = new URL(url).getHost(); - } catch (MalformedURLException e) { - } - } - if (StringUtils.isEmpty(host)) { - host = SystemReader.getInstance().getHostname(); - } - return host; - } - - private String formatUrl(String hostname, int port, String username) { - int displayPort = settings.getInteger(Keys.git.sshAdvertisedPort, port); - String displayHostname = settings.getString(Keys.git.sshAdvertisedHost, ""); - if(displayHostname.isEmpty()) { - displayHostname = hostname; - } - if (displayPort == 22) { - // standard port - return MessageFormat.format("{0}@{1}/REPOSITORY.git", username, displayHostname); - } else { - // non-standard port - return MessageFormat.format("ssh://{0}@{1}:{2,number,0}/REPOSITORY.git", - username, displayHostname, displayPort); - } - } - } +public class WelcomeShell implements ShellFactory { + + private final IGitblit gitblit; + + public WelcomeShell(IGitblit gitblit) { + this.gitblit = gitblit; + } + + @Override + public Command createShell(ChannelSession channel) throws IOException { + return new SendMessage(gitblit); + } + + private static class SendMessage implements Command, ServerSessionAware { + + private final IPublicKeyManager km; + private final IStoredSettings settings; + private ServerSession session; + + private InputStream in; + private OutputStream out; + private OutputStream err; + private ExitCallback exit; + + SendMessage(IGitblit gitblit) { + this.km = gitblit.getPublicKeyManager(); + this.settings = gitblit.getSettings(); + } + + @Override + public void setInputStream(InputStream in) { + this.in = in; + } + + @Override + public void setOutputStream(OutputStream out) { + this.out = out; + } + + @Override + public void setErrorStream(OutputStream err) { + this.err = err; + } + + @Override + public void setExitCallback(ExitCallback callback) { + this.exit = callback; + } + + @Override + public void setSession(ServerSession session) { + this.session = session; + } + + @Override + public void start(ChannelSession channel, Environment env) throws IOException { + err.write(Constants.encode(getMessage())); + err.flush(); + + in.close(); + out.close(); + err.close(); + exit.onExit(127); + } + + @Override + public void destroy(ChannelSession channel) { + this.session = null; + } + + String getMessage() { + SshDaemonClient client = SshDaemonClient.get(session); + UserModel user = client.getUser(); + String hostname = getHostname(); + int port = settings.getInteger(Keys.git.sshPort, 0); + boolean writeKeysIsSupported = true; + if (km != null) { + writeKeysIsSupported = km.supportsWritingKeys(user); + } + + final String b1 = StringUtils.rightPad("", 72, '═'); + final String b2 = StringUtils.rightPad("", 72, '─'); + final String nl = "\r\n"; + + StringBuilder msg = new StringBuilder(); + msg.append(nl); + msg.append(b1); + msg.append(nl); + msg.append(" "); + msg.append(com.gitblit.Constants.getGitBlitVersion()); + msg.append(nl); + msg.append(b1); + msg.append(nl); + msg.append(nl); + msg.append(" Hi "); + msg.append(user.getDisplayName()); + msg.append(", you have successfully connected over SSH."); + msg.append(nl); + msg.append(" Interactive shells are not available."); + msg.append(nl); + msg.append(nl); + msg.append(" client: "); + msg.append(session.getClientVersion()); + msg.append(nl); + msg.append(nl); + + msg.append(b2); + msg.append(nl); + msg.append(nl); + msg.append(" You may clone a repository with the following Git syntax:"); + msg.append(nl); + msg.append(nl); + + msg.append(" git clone "); + msg.append(formatUrl(hostname, port, user.username)); + msg.append(nl); + msg.append(nl); + + msg.append(b2); + msg.append(nl); + msg.append(nl); + + if (writeKeysIsSupported && client.getKey() == null) { + msg.append(" You may upload an SSH public key with the following syntax:"); + msg.append(nl); + msg.append(nl); + + msg.append(String.format(" cat ~/.ssh/id_rsa.pub | ssh -l %s -p %d %s keys add", user.username, port, hostname)); + msg.append(nl); + msg.append(nl); + + msg.append(b2); + msg.append(nl); + msg.append(nl); + } + + SshCommandFactory cmdFactory = (SshCommandFactory) session.getFactoryManager().getCommandFactory(); + DispatchCommand root = cmdFactory.createRootDispatcher(client, ""); + String usage = root.usage().replace("\n", nl); + msg.append(usage); + + return msg.toString(); + } + + private String getHostname() { + String host = null; + String url = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443"); + if (url != null) { + try { + host = new URL(url).getHost(); + } catch (MalformedURLException e) { + } + } + if (StringUtils.isEmpty(host)) { + host = SystemReader.getInstance().getHostname(); + } + return host; + } + + private String formatUrl(String hostname, int port, String username) { + int displayPort = settings.getInteger(Keys.git.sshAdvertisedPort, port); + String displayHostname = settings.getString(Keys.git.sshAdvertisedHost, ""); + if (displayHostname.isEmpty()) { + displayHostname = hostname; + } + if (displayPort == 22) { + return MessageFormat.format("ssh://{0}@{1}/{2}", username, displayHostname, "${repository}"); + } + return MessageFormat.format("ssh://{0}@{1}:{2,number,0}/{3}", username, displayHostname, displayPort, "${repository}"); + } + } } diff --git a/src/main/java/com/gitblit/transport/ssh/commands/BaseCommand.java b/src/main/java/com/gitblit/transport/ssh/commands/BaseCommand.java index ab2756d02..a953b3d20 100644 --- a/src/main/java/com/gitblit/transport/ssh/commands/BaseCommand.java +++ b/src/main/java/com/gitblit/transport/ssh/commands/BaseCommand.java @@ -28,11 +28,12 @@ import java.util.concurrent.atomic.AtomicReference; import org.apache.sshd.common.SshException; -import org.apache.sshd.server.Command; import org.apache.sshd.server.Environment; import org.apache.sshd.server.ExitCallback; -import org.apache.sshd.server.SessionAware; +import org.apache.sshd.server.channel.ChannelSession; +import org.apache.sshd.server.command.Command; import org.apache.sshd.server.session.ServerSession; +import org.apache.sshd.server.session.ServerSessionAware; import org.kohsuke.args4j.Argument; import org.kohsuke.args4j.CmdLineException; import org.kohsuke.args4j.Option; @@ -47,522 +48,380 @@ import com.google.common.base.Charsets; import com.google.common.util.concurrent.Atomics; -public abstract class BaseCommand implements Command, SessionAware { - - private static final Logger log = LoggerFactory.getLogger(BaseCommand.class); - - private static final int PRIVATE_STATUS = 1 << 30; - - public final static int STATUS_CANCEL = PRIVATE_STATUS | 1; - - public final static int STATUS_NOT_FOUND = PRIVATE_STATUS | 2; - - public final static int STATUS_NOT_ADMIN = PRIVATE_STATUS | 3; - - protected InputStream in; - - protected OutputStream out; - - protected OutputStream err; - - protected ExitCallback exit; - - protected ServerSession session; - - /** Ssh command context */ - private SshCommandContext ctx; - - /** Text of the command line which lead up to invoking this instance. */ - private String commandName = ""; - - /** Unparsed command line options. */ - private String[] argv; - - /** The task, as scheduled on a worker thread. */ - private final AtomicReference> task; - - private WorkQueue workQueue; - - public BaseCommand() { - task = Atomics.newReference(); - } - - @Override - public void setSession(final ServerSession session) { - this.session = session; - } - - @Override - public void destroy() { - log.debug("destroying " + getClass().getName()); - Future future = task.getAndSet(null); - if (future != null && !future.isDone()) { - future.cancel(true); - } - session = null; - ctx = null; - } - - protected static PrintWriter toPrintWriter(final OutputStream o) { - return new PrintWriter(new BufferedWriter(new OutputStreamWriter(o, Charsets.UTF_8))); - } - - @Override - public abstract void start(Environment env) throws IOException; - - protected void provideStateTo(final BaseCommand cmd) { - cmd.setContext(ctx); - cmd.setWorkQueue(workQueue); - cmd.setInputStream(in); - cmd.setOutputStream(out); - cmd.setErrorStream(err); - cmd.setExitCallback(exit); - } - - public WorkQueue getWorkQueue() { - return workQueue; - } - - public void setWorkQueue(WorkQueue workQueue) { - this.workQueue = workQueue; - } - - public void setContext(SshCommandContext ctx) { - this.ctx = ctx; - } - - public SshCommandContext getContext() { - return ctx; - } - - @Override - public void setInputStream(final InputStream in) { - this.in = in; - } - - @Override - public void setOutputStream(final OutputStream out) { - this.out = out; - } - - @Override - public void setErrorStream(final OutputStream err) { - this.err = err; - } - - @Override - public void setExitCallback(final ExitCallback callback) { - this.exit = callback; - } - - protected String getName() { - return commandName; - } - - void setName(final String prefix) { - this.commandName = prefix; - } - - public String[] getArguments() { - return argv; - } - - public void setArguments(final String[] argv) { - this.argv = argv; - } - - /** - * Parses the command line argument, injecting parsed values into fields. - *

- * This method must be explicitly invoked to cause a parse. - * - * @throws UnloggedFailure - * if the command line arguments were invalid. - * @see Option - * @see Argument - */ - protected void parseCommandLine() throws UnloggedFailure { - parseCommandLine(this); - } - - /** - * Parses the command line argument, injecting parsed values into fields. - *

- * This method must be explicitly invoked to cause a parse. - * - * @param options - * object whose fields declare Option and Argument annotations to - * describe the parameters of the command. Usually {@code this}. - * @throws UnloggedFailure - * if the command line arguments were invalid. - * @see Option - * @see Argument - */ - protected void parseCommandLine(Object options) throws UnloggedFailure { - final CmdLineParser clp = newCmdLineParser(options); - try { - clp.parseArgument(argv); - } catch (IllegalArgumentException err) { - if (!clp.wasHelpRequestedByOption()) { - throw new UnloggedFailure(1, "fatal: " + err.getMessage()); - } - } catch (CmdLineException err) { - if (!clp.wasHelpRequestedByOption()) { - throw new UnloggedFailure(1, "fatal: " + err.getMessage()); - } - } - - if (clp.wasHelpRequestedByOption()) { - CommandMetaData meta = getClass().getAnnotation(CommandMetaData.class); - String title = meta.name().toUpperCase() + ": " + meta.description(); - String b = com.gitblit.utils.StringUtils.leftPad("", title.length() + 2, '═'); - StringWriter msg = new StringWriter(); - msg.write('\n'); - msg.write(b); - msg.write('\n'); - msg.write(' '); - msg.write(title); - msg.write('\n'); - msg.write(b); - msg.write("\n\n"); - msg.write("USAGE\n"); - msg.write("─────\n"); - msg.write(' '); - msg.write(commandName); - msg.write('\n'); - msg.write(" "); - clp.printSingleLineUsage(msg, null); - msg.write("\n\n"); - String txt = getUsageText(); - if (!StringUtils.isEmpty(txt)) { - msg.write(txt); - msg.write("\n\n"); - } - msg.write("ARGUMENTS & OPTIONS\n"); - msg.write("───────────────────\n"); - clp.printUsage(msg, null); - msg.write('\n'); - String examples = usage().trim(); - if (!StringUtils.isEmpty(examples)) { - msg.write('\n'); - msg.write("EXAMPLES\n"); - msg.write("────────\n"); - msg.write(examples); - msg.write('\n'); - } - - throw new UnloggedFailure(1, msg.toString()); - } - } - - /** Construct a new parser for this command's received command line. */ - protected CmdLineParser newCmdLineParser(Object options) { - return new CmdLineParser(options); - } - - public String usage() { - Class clazz = getClass(); - if (clazz.isAnnotationPresent(UsageExamples.class)) { - return examples(clazz.getAnnotation(UsageExamples.class).examples()); - } else if (clazz.isAnnotationPresent(UsageExample.class)) { - return examples(clazz.getAnnotation(UsageExample.class)); - } - return ""; - } - - protected String getUsageText() { - return ""; - } - - protected String examples(UsageExample... examples) { - int sshPort = getContext().getGitblit().getSettings().getInteger(Keys.git.sshPort, 29418); - String username = getContext().getClient().getUsername(); - String hostname = "localhost"; - String ssh = String.format("ssh -l %s -p %d %s", username, sshPort, hostname); - - StringBuilder sb = new StringBuilder(); - for (UsageExample example : examples) { - sb.append(example.description()).append("\n\n"); - String syntax = example.syntax(); - syntax = syntax.replace("${ssh}", ssh); - syntax = syntax.replace("${username}", username); - syntax = syntax.replace("${cmd}", commandName); - sb.append(" ").append(syntax).append("\n\n"); - } - return sb.toString(); - } - - protected void showHelp() throws UnloggedFailure { - argv = new String [] { "--help" }; - parseCommandLine(); - } - - private final class TaskThunk implements CancelableRunnable { - private final CommandRunnable thunk; - private final String taskName; - - private TaskThunk(final CommandRunnable thunk) { - this.thunk = thunk; - - StringBuilder m = new StringBuilder(); - m.append(ctx.getCommandLine()); - this.taskName = m.toString(); - } - - @Override - public void cancel() { - synchronized (this) { - try { - onExit(STATUS_CANCEL); - } finally { - ctx = null; - } - } - } - - @Override - public void run() { - synchronized (this) { - final Thread thisThread = Thread.currentThread(); - final String thisName = thisThread.getName(); - int rc = 0; - try { - thisThread.setName("SSH " + taskName); - thunk.run(); - - out.flush(); - err.flush(); - } catch (Throwable e) { - try { - out.flush(); - } catch (Throwable e2) { - } - try { - err.flush(); - } catch (Throwable e2) { - } - rc = handleError(e); - } finally { - try { - onExit(rc); - } finally { - thisThread.setName(thisName); - } - } - } - } - - @Override - public String toString() { - return taskName; - } - } - - /** Runnable function which can throw an exception. */ - public interface CommandRunnable { - void run() throws Exception; - } - - /** Runnable function which can retrieve a project name related to the task */ - public interface RepositoryCommandRunnable extends CommandRunnable { - String getRepository(); - } - - /** - * Spawn a function into its own thread. - *

- * Typically this should be invoked within - * {@link Command#start(Environment)}, such as: - * - *

-	 * startThread(new Runnable() {
-	 * 	public void run() {
-	 * 		runImp();
-	 * 	}
-	 * });
-	 * 
- * - * @param thunk - * the runnable to execute on the thread, performing the - * command's logic. - */ - protected void startThread(final Runnable thunk) { - startThread(new CommandRunnable() { - @Override - public void run() throws Exception { - thunk.run(); - } - }); - } - - /** - * Terminate this command and return a result code to the remote client. - *

- * Commands should invoke this at most once. - * - * @param rc exit code for the remote client. - */ - protected void onExit(final int rc) { - exit.onExit(rc); - } - - private int handleError(final Throwable e) { - if ((e.getClass() == IOException.class && "Pipe closed".equals(e.getMessage())) || - (e.getClass() == SshException.class && "Already closed".equals(e.getMessage())) || - e.getClass() == InterruptedIOException.class) { - // This is sshd telling us the client just dropped off while - // we were waiting for a read or a write to complete. Either - // way its not really a fatal error. Don't log it. - // - return 127; - } - - if (e instanceof UnloggedFailure) { - } else { - final StringBuilder m = new StringBuilder(); - m.append("Internal server error"); - String user = ctx.getClient().getUsername(); - if (user != null) { - m.append(" (user "); - m.append(user); - m.append(")"); - } - m.append(" during "); - m.append(ctx.getCommandLine()); - log.error(m.toString(), e); - } - - if (e instanceof Failure) { - final Failure f = (Failure) e; - try { - err.write((f.getMessage() + "\n").getBytes(Charsets.UTF_8)); - err.flush(); - } catch (IOException e2) { - } catch (Throwable e2) { - log.warn("Cannot send failure message to client", e2); - } - return f.exitCode; - - } else { - try { - err.write("fatal: internal server error\n".getBytes(Charsets.UTF_8)); - err.flush(); - } catch (IOException e2) { - } catch (Throwable e2) { - log.warn("Cannot send internal server error message to client", e2); - } - return 128; - } - } - - /** - * Spawn a function into its own thread. - *

- * Typically this should be invoked within - * {@link Command#start(Environment)}, such as: - * - *

-	 * startThread(new CommandRunnable() {
-	 * 	public void run() throws Exception {
-	 * 		runImp();
-	 * 	}
-	 * });
-	 * 
- *

- * If the function throws an exception, it is translated to a simple message - * for the client, a non-zero exit code, and the stack trace is logged. - * - * @param thunk - * the runnable to execute on the thread, performing the - * command's logic. - */ - protected void startThread(final CommandRunnable thunk) { - final TaskThunk tt = new TaskThunk(thunk); - task.set(workQueue.getDefaultQueue().submit(tt)); - } - - /** Thrown from {@link CommandRunnable#run()} with client message and code. */ - public static class Failure extends Exception { - private static final long serialVersionUID = 1L; - - final int exitCode; - - /** - * Create a new failure. - * - * @param exitCode - * exit code to return the client, which indicates the - * failure status of this command. Should be between 1 and - * 255, inclusive. - * @param msg - * message to also send to the client's stderr. - */ - public Failure(final int exitCode, final String msg) { - this(exitCode, msg, null); - } - - /** - * Create a new failure. - * - * @param exitCode - * exit code to return the client, which indicates the - * failure status of this command. Should be between 1 and - * 255, inclusive. - * @param msg - * message to also send to the client's stderr. - * @param why - * stack trace to include in the server's log, but is not - * sent to the client's stderr. - */ - public Failure(final int exitCode, final String msg, final Throwable why) { - super(msg, why); - this.exitCode = exitCode; - } - } - - /** Thrown from {@link CommandRunnable#run()} with client message and code. */ - public static class UnloggedFailure extends Failure { - private static final long serialVersionUID = 1L; - - /** - * Create a new failure. - * - * @param msg - * message to also send to the client's stderr. - */ - public UnloggedFailure(final String msg) { - this(1, msg); - } - - /** - * Create a new failure. - * - * @param exitCode - * exit code to return the client, which indicates the - * failure status of this command. Should be between 1 and - * 255, inclusive. - * @param msg - * message to also send to the client's stderr. - */ - public UnloggedFailure(final int exitCode, final String msg) { - this(exitCode, msg, null); - } - - /** - * Create a new failure. - * - * @param exitCode - * exit code to return the client, which indicates the - * failure status of this command. Should be between 1 and - * 255, inclusive. - * @param msg - * message to also send to the client's stderr. - * @param why - * stack trace to include in the server's log, but is not - * sent to the client's stderr. - */ - public UnloggedFailure(final int exitCode, final String msg, final Throwable why) { - super(exitCode, msg, why); - } - } +public abstract class BaseCommand implements Command, ServerSessionAware { + + private static final Logger log = LoggerFactory.getLogger(BaseCommand.class); + private static final int PRIVATE_STATUS = 1 << 30; + + public static final int STATUS_CANCEL = PRIVATE_STATUS | 1; + public static final int STATUS_NOT_FOUND = PRIVATE_STATUS | 2; + public static final int STATUS_NOT_ADMIN = PRIVATE_STATUS | 3; + + protected InputStream in; + protected OutputStream out; + protected OutputStream err; + protected ExitCallback exit; + protected ServerSession session; + + private SshCommandContext ctx; + private String commandName = ""; + private String[] argv; + private final AtomicReference> task; + private WorkQueue workQueue; + + public BaseCommand() { + task = Atomics.newReference(); + } + + @Override + public void setSession(ServerSession session) { + this.session = session; + } + + public void destroy() { + log.debug("destroying " + getClass().getName()); + Future future = task.getAndSet(null); + if (future != null && !future.isDone()) { + future.cancel(true); + } + session = null; + ctx = null; + } + + @Override + public final void destroy(ChannelSession channel) { + destroy(); + } + + protected static PrintWriter toPrintWriter(OutputStream o) { + return new PrintWriter(new BufferedWriter(new OutputStreamWriter(o, Charsets.UTF_8))); + } + + @Override + public final void start(ChannelSession channel, Environment env) throws IOException { + start(env); + } + + public abstract void start(Environment env) throws IOException; + + protected void provideStateTo(BaseCommand cmd) { + cmd.setContext(ctx); + cmd.setWorkQueue(workQueue); + cmd.setInputStream(in); + cmd.setOutputStream(out); + cmd.setErrorStream(err); + cmd.setExitCallback(exit); + } + + public WorkQueue getWorkQueue() { + return workQueue; + } + + public void setWorkQueue(WorkQueue workQueue) { + this.workQueue = workQueue; + } + + public void setContext(SshCommandContext ctx) { + this.ctx = ctx; + } + + public SshCommandContext getContext() { + return ctx; + } + + @Override + public void setInputStream(InputStream in) { + this.in = in; + } + + @Override + public void setOutputStream(OutputStream out) { + this.out = out; + } + + @Override + public void setErrorStream(OutputStream err) { + this.err = err; + } + + @Override + public void setExitCallback(ExitCallback callback) { + this.exit = callback; + } + + protected String getName() { + return commandName; + } + + void setName(String prefix) { + this.commandName = prefix; + } + + public String[] getArguments() { + return argv; + } + + public void setArguments(String[] argv) { + this.argv = argv; + } + + protected void parseCommandLine() throws UnloggedFailure { + parseCommandLine(this); + } + + protected void parseCommandLine(Object options) throws UnloggedFailure { + final CmdLineParser clp = newCmdLineParser(options); + try { + clp.parseArgument(argv); + } catch (IllegalArgumentException err) { + if (!clp.wasHelpRequestedByOption()) { + throw new UnloggedFailure(1, "fatal: " + err.getMessage()); + } + } catch (CmdLineException err) { + if (!clp.wasHelpRequestedByOption()) { + throw new UnloggedFailure(1, "fatal: " + err.getMessage()); + } + } + + if (clp.wasHelpRequestedByOption()) { + CommandMetaData meta = getClass().getAnnotation(CommandMetaData.class); + String title = meta.name().toUpperCase() + ": " + meta.description(); + String b = com.gitblit.utils.StringUtils.leftPad("", title.length() + 2, '═'); + StringWriter msg = new StringWriter(); + msg.write('\n'); + msg.write(b); + msg.write('\n'); + msg.write(' '); + msg.write(title); + msg.write('\n'); + msg.write(b); + msg.write("\n\n"); + msg.write("USAGE\n"); + msg.write("─────\n"); + msg.write(' '); + msg.write(commandName); + msg.write('\n'); + msg.write(" "); + clp.printSingleLineUsage(msg, null); + msg.write("\n\n"); + String txt = getUsageText(); + if (!StringUtils.isEmpty(txt)) { + msg.write(txt); + msg.write("\n\n"); + } + msg.write("ARGUMENTS & OPTIONS\n"); + msg.write("───────────────────\n"); + clp.printUsage(msg, null); + msg.write('\n'); + String examples = usage().trim(); + if (!StringUtils.isEmpty(examples)) { + msg.write('\n'); + msg.write("EXAMPLES\n"); + msg.write("────────\n"); + msg.write(examples); + msg.write('\n'); + } + + throw new UnloggedFailure(1, msg.toString()); + } + } + + protected CmdLineParser newCmdLineParser(Object options) { + return new CmdLineParser(options); + } + + public String usage() { + Class clazz = getClass(); + if (clazz.isAnnotationPresent(UsageExamples.class)) { + return examples(clazz.getAnnotation(UsageExamples.class).examples()); + } else if (clazz.isAnnotationPresent(UsageExample.class)) { + return examples(clazz.getAnnotation(UsageExample.class)); + } + return ""; + } + + protected String getUsageText() { + return ""; + } + + protected String examples(UsageExample... examples) { + int sshPort = getContext().getGitblit().getSettings().getInteger(Keys.git.sshPort, 29418); + String username = getContext().getClient().getUsername(); + String hostname = "localhost"; + String ssh = String.format("ssh -l %s -p %d %s", username, sshPort, hostname); + + StringBuilder sb = new StringBuilder(); + for (UsageExample example : examples) { + sb.append(example.description()).append("\n\n"); + String syntax = example.syntax(); + syntax = syntax.replace("${ssh}", ssh); + syntax = syntax.replace("${username}", username); + syntax = syntax.replace("${cmd}", commandName); + sb.append(" ").append(syntax).append("\n\n"); + } + return sb.toString(); + } + + protected void showHelp() throws UnloggedFailure { + argv = new String[] { "--help" }; + parseCommandLine(); + } + + private final class TaskThunk implements CancelableRunnable { + private final CommandRunnable thunk; + private final String taskName; + + private TaskThunk(CommandRunnable thunk) { + this.thunk = thunk; + this.taskName = ctx.getCommandLine(); + } + + @Override + public void cancel() { + synchronized (this) { + try { + onExit(STATUS_CANCEL); + } finally { + ctx = null; + } + } + } + + @Override + public void run() { + synchronized (this) { + final Thread thisThread = Thread.currentThread(); + final String thisName = thisThread.getName(); + int rc = 0; + try { + thisThread.setName("SSH " + taskName); + thunk.run(); + + out.flush(); + err.flush(); + } catch (Throwable e) { + try { + out.flush(); + } catch (Throwable e2) { + } + try { + err.flush(); + } catch (Throwable e2) { + } + rc = handleError(e); + } finally { + try { + onExit(rc); + } finally { + thisThread.setName(thisName); + } + } + } + } + + @Override + public String toString() { + return taskName; + } + } + + public interface CommandRunnable { + void run() throws Exception; + } + + public interface RepositoryCommandRunnable extends CommandRunnable { + String getRepository(); + } + + protected void startThread(final Runnable thunk) { + startThread(new CommandRunnable() { + @Override + public void run() throws Exception { + thunk.run(); + } + }); + } + + protected void onExit(final int rc) { + exit.onExit(rc); + } + + private int handleError(final Throwable e) { + if ((e.getClass() == IOException.class && "Pipe closed".equals(e.getMessage())) + || (e.getClass() == SshException.class && "Already closed".equals(e.getMessage())) + || e.getClass() == InterruptedIOException.class) { + return 127; + } + + if (!(e instanceof UnloggedFailure)) { + final StringBuilder m = new StringBuilder(); + m.append("Internal server error"); + String user = ctx.getClient().getUsername(); + if (user != null) { + m.append(" (user "); + m.append(user); + m.append(")"); + } + m.append(" during "); + m.append(ctx.getCommandLine()); + log.error(m.toString(), e); + } + + if (e instanceof Failure) { + final Failure f = (Failure) e; + try { + err.write((f.getMessage() + "\n").getBytes(Charsets.UTF_8)); + err.flush(); + } catch (IOException e2) { + } catch (Throwable e2) { + log.warn("Cannot send failure message to client", e2); + } + return f.exitCode; + } + + try { + err.write("fatal: internal server error\n".getBytes(Charsets.UTF_8)); + err.flush(); + } catch (IOException e2) { + } catch (Throwable e2) { + log.warn("Cannot send internal server error message to client", e2); + } + return 128; + } + + protected void startThread(final CommandRunnable thunk) { + final TaskThunk tt = new TaskThunk(thunk); + task.set(workQueue.getDefaultQueue().submit(tt)); + } + + public static class Failure extends Exception { + private static final long serialVersionUID = 1L; + + final int exitCode; + + public Failure(final int exitCode, final String msg) { + this(exitCode, msg, null); + } + + public Failure(final int exitCode, final String msg, final Throwable why) { + super(msg, why); + this.exitCode = exitCode; + } + } + + public static class UnloggedFailure extends Failure { + private static final long serialVersionUID = 1L; + + public UnloggedFailure(final String msg) { + this(1, msg); + } + + public UnloggedFailure(final int exitCode, final String msg) { + this(exitCode, msg, null); + } + + public UnloggedFailure(final int exitCode, final String msg, final Throwable why) { + super(exitCode, msg, why); + } + } } diff --git a/src/main/java/com/gitblit/transport/ssh/commands/DispatchCommand.java b/src/main/java/com/gitblit/transport/ssh/commands/DispatchCommand.java index d17a4ebf1..e9cf01afd 100644 --- a/src/main/java/com/gitblit/transport/ssh/commands/DispatchCommand.java +++ b/src/main/java/com/gitblit/transport/ssh/commands/DispatchCommand.java @@ -26,7 +26,6 @@ import java.util.Set; import java.util.TreeSet; -import org.apache.sshd.server.Command; import org.apache.sshd.server.Environment; import org.kohsuke.args4j.Argument; import org.slf4j.Logger; @@ -42,385 +41,258 @@ import com.google.common.base.Strings; import com.google.common.collect.Maps; -/** - * Parses an SSH command-line and dispatches the command to the appropriate - * BaseCommand instance. - * - * @since 1.5.0 - */ public abstract class DispatchCommand extends BaseCommand implements ExtensionPoint { - private Logger log = LoggerFactory.getLogger(getClass()); - - @Argument(index = 0, required = false, metaVar = "COMMAND", handler = SubcommandHandler.class) - private String commandName; - - @Argument(index = 1, multiValued = true, metaVar = "ARG") - private List args = new ArrayList(); - - private final Set> commands; - private final Map dispatchers; - private final Map aliasToCommand; - private final Map> commandToAliases; - private final List instantiated; - private Map> map; - - protected DispatchCommand() { - commands = new HashSet>(); - dispatchers = Maps.newHashMap(); - aliasToCommand = Maps.newHashMap(); - commandToAliases = Maps.newHashMap(); - instantiated = new ArrayList(); - } - - @Override - public void destroy() { - super.destroy(); - commands.clear(); - aliasToCommand.clear(); - commandToAliases.clear(); - map = null; - - for (BaseCommand command : instantiated) { - command.destroy(); - } - instantiated.clear(); - - for (DispatchCommand dispatcher : dispatchers.values()) { - dispatcher.destroy(); - } - dispatchers.clear(); - } - - /** - * Setup this dispatcher. Commands and nested dispatchers are normally - * registered within this method. - * - * @since 1.5.0 - */ - protected abstract void setup(); - - /** - * Register a command or a dispatcher by it's class. - * - * @param clazz - */ - @SuppressWarnings("unchecked") - protected final void register(Class clazz) { - if (DispatchCommand.class.isAssignableFrom(clazz)) { - registerDispatcher((Class) clazz); - return; - } - - registerCommand(clazz); - } - - /** - * Register a command or a dispatcher instance. - * - * @param cmd - */ - protected final void register(BaseCommand cmd) { - if (cmd instanceof DispatchCommand) { - registerDispatcher((DispatchCommand) cmd); - return; - } - registerCommand(cmd); - } - - private void registerDispatcher(Class clazz) { - try { - DispatchCommand dispatcher = clazz.newInstance(); - registerDispatcher(dispatcher); - } catch (Exception e) { - log.error("failed to instantiate {}", clazz.getName()); - } - } - - private void registerDispatcher(DispatchCommand dispatcher) { - Class dispatcherClass = dispatcher.getClass(); - if (!dispatcherClass.isAnnotationPresent(CommandMetaData.class)) { - throw new RuntimeException(MessageFormat.format("{0} must be annotated with {1}!", dispatcher.getName(), - CommandMetaData.class.getName())); + private Logger log = LoggerFactory.getLogger(getClass()); + + @Argument(index = 0, required = false, metaVar = "COMMAND", handler = SubcommandHandler.class) + private String commandName; + + @Argument(index = 1, multiValued = true, metaVar = "ARG") + private List args = new ArrayList(); + + private final Set> commands; + private final Map dispatchers; + private final Map aliasToCommand; + private final Map> commandToAliases; + private final List instantiated; + private Map> map; + + protected DispatchCommand() { + commands = new HashSet>(); + dispatchers = Maps.newHashMap(); + aliasToCommand = Maps.newHashMap(); + commandToAliases = Maps.newHashMap(); + instantiated = new ArrayList(); + } + + @Override + public void destroy() { + super.destroy(); + commands.clear(); + aliasToCommand.clear(); + commandToAliases.clear(); + map = null; + + for (BaseCommand command : instantiated) { + command.destroy(); + } + instantiated.clear(); + + for (DispatchCommand dispatcher : dispatchers.values()) { + dispatcher.destroy(); + } + dispatchers.clear(); + } + + protected abstract void setup(); + + @SuppressWarnings("unchecked") + protected final void register(Class clazz) { + if (DispatchCommand.class.isAssignableFrom(clazz)) { + registerDispatcher((Class) clazz); + return; + } + + registerCommand(clazz); + } + + protected final void register(BaseCommand cmd) { + if (cmd instanceof DispatchCommand) { + registerDispatcher((DispatchCommand) cmd); + return; + } + registerCommand(cmd); + } + + private void registerDispatcher(Class clazz) { + try { + DispatchCommand dispatcher = clazz.newInstance(); + registerDispatcher(dispatcher); + } catch (Exception e) { + log.error("failed to instantiate {}", clazz.getName()); + } + } + + private void registerDispatcher(DispatchCommand dispatcher) { + Class dispatcherClass = dispatcher.getClass(); + if (!dispatcherClass.isAnnotationPresent(CommandMetaData.class)) { + throw new RuntimeException(MessageFormat.format("{0} must be annotated with {1}!", dispatcher.getName(), + CommandMetaData.class.getName())); + } + + UserModel user = getContext().getClient().getUser(); + CommandMetaData meta = dispatcherClass.getAnnotation(CommandMetaData.class); + if (meta.admin() && !user.canAdmin()) { + log.debug(MessageFormat.format("excluding admin dispatcher {0} for {1}", meta.name(), user.username)); + return; + } + + try { + dispatcher.setContext(getContext()); + dispatcher.setWorkQueue(getWorkQueue()); + dispatcher.setup(); + if (dispatcher.commands.isEmpty() && dispatcher.dispatchers.isEmpty()) { + log.debug(MessageFormat.format("excluding empty dispatcher {0} for {1}", meta.name(), user.username)); + return; + } + + log.debug("registering {} dispatcher", meta.name()); + dispatchers.put(meta.name(), dispatcher); + for (String alias : meta.aliases()) { + aliasToCommand.put(alias, meta.name()); + if (!commandToAliases.containsKey(meta.name())) { + commandToAliases.put(meta.name(), new ArrayList()); + } + commandToAliases.get(meta.name()).add(alias); + } + } catch (Exception e) { + log.error("failed to register {} dispatcher", meta.name()); + } + } + + private void registerCommand(Class clazz) { + if (!clazz.isAnnotationPresent(CommandMetaData.class)) { + throw new RuntimeException(MessageFormat.format("{0} must be annotated with {1}!", clazz.getName(), + CommandMetaData.class.getName())); + } + + UserModel user = getContext().getClient().getUser(); + CommandMetaData meta = clazz.getAnnotation(CommandMetaData.class); + if (meta.admin() && !user.canAdmin()) { + log.debug(MessageFormat.format("excluding admin command {0} for {1}", meta.name(), user.username)); + return; + } + commands.add(clazz); + } + + private void registerCommand(BaseCommand cmd) { + if (!cmd.getClass().isAnnotationPresent(CommandMetaData.class)) { + throw new RuntimeException(MessageFormat.format("{0} must be annotated with {1}!", cmd.getName(), + CommandMetaData.class.getName())); + } + + UserModel user = getContext().getClient().getUser(); + CommandMetaData meta = cmd.getClass().getAnnotation(CommandMetaData.class); + if (meta.admin() && !user.canAdmin()) { + log.debug(MessageFormat.format("excluding admin command {0} for {1}", meta.name(), user.username)); + return; + } + commands.add(cmd.getClass()); + instantiated.add(cmd); + } + + private Map> getMap() { + if (map == null) { + map = Maps.newHashMapWithExpectedSize(commands.size()); + for (Class cmd : commands) { + CommandMetaData meta = cmd.getAnnotation(CommandMetaData.class); + if (map.containsKey(meta.name()) || aliasToCommand.containsKey(meta.name())) { + log.warn("{} already contains the \"{}\" command!", getName(), meta.name()); + } else { + map.put(meta.name(), cmd); + } + for (String alias : meta.aliases()) { + if (map.containsKey(alias) || aliasToCommand.containsKey(alias)) { + log.warn("{} already contains the \"{}\" command!", getName(), alias); + } else { + aliasToCommand.put(alias, meta.name()); + if (!commandToAliases.containsKey(meta.name())) { + commandToAliases.put(meta.name(), new ArrayList()); + } + commandToAliases.get(meta.name()).add(alias); + } + } + } + + for (Map.Entry entry : dispatchers.entrySet()) { + map.put(entry.getKey(), entry.getValue().getClass()); + } + } + return map; + } + + @Override + public void start(Environment env) throws IOException { + try { + parseCommandLine(); + if (Strings.isNullOrEmpty(commandName)) { + StringWriter msg = new StringWriter(); + msg.write(usage()); + throw new UnloggedFailure(1, msg.toString()); + } + + BaseCommand cmd = getCommand(); + if (getName().isEmpty()) { + cmd.setName(commandName); + } else { + cmd.setName(getName() + " " + commandName); + } + cmd.setArguments(args.toArray(new String[args.size()])); + + provideStateTo(cmd); + cmd.start(env); + + } catch (UnloggedFailure e) { + String msg = e.getMessage(); + if (!msg.endsWith("\n")) { + msg += "\n"; + } + err.write(msg.getBytes(Charsets.UTF_8)); + err.flush(); + exit.onExit(e.exitCode); + } + } + + protected BaseCommand getCommand() throws UnloggedFailure { + Map> commandMap = getMap(); + String cmd = commandName; + if (aliasToCommand.containsKey(cmd)) { + cmd = aliasToCommand.get(cmd); } - UserModel user = getContext().getClient().getUser(); - CommandMetaData meta = dispatcherClass.getAnnotation(CommandMetaData.class); - if (meta.admin() && !user.canAdmin()) { - log.debug(MessageFormat.format("excluding admin dispatcher {0} for {1}", - meta.name(), user.username)); - return; + Class clazz = commandMap.get(cmd); + if (clazz == null) { + throw new UnloggedFailure(STATUS_NOT_FOUND, "fatal: '" + commandName + "' is not a registered command"); } - try { - dispatcher.setContext(getContext()); - dispatcher.setWorkQueue(getWorkQueue()); - dispatcher.setup(); - if (dispatcher.commands.isEmpty() && dispatcher.dispatchers.isEmpty()) { - log.debug(MessageFormat.format("excluding empty dispatcher {0} for {1}", - meta.name(), user.username)); - return; - } - - log.debug("registering {} dispatcher", meta.name()); - dispatchers.put(meta.name(), dispatcher); - for (String alias : meta.aliases()) { - aliasToCommand.put(alias, meta.name()); - if (!commandToAliases.containsKey(meta.name())) { - commandToAliases.put(meta.name(), new ArrayList()); - } - commandToAliases.get(meta.name()).add(alias); - } - } catch (Exception e) { - log.error("failed to register {} dispatcher", meta.name()); - } - } - - /** - * Registers a command as long as the user is permitted to execute it. - * - * @param clazz - */ - private void registerCommand(Class clazz) { - if (!clazz.isAnnotationPresent(CommandMetaData.class)) { - throw new RuntimeException(MessageFormat.format("{0} must be annotated with {1}!", clazz.getName(), - CommandMetaData.class.getName())); - } - - UserModel user = getContext().getClient().getUser(); - CommandMetaData meta = clazz.getAnnotation(CommandMetaData.class); - if (meta.admin() && !user.canAdmin()) { - log.debug(MessageFormat.format("excluding admin command {0} for {1}", meta.name(), user.username)); - return; - } - commands.add(clazz); - } - - /** - * Registers a command as long as the user is permitted to execute it. - * - * @param cmd - */ - private void registerCommand(BaseCommand cmd) { - if (!cmd.getClass().isAnnotationPresent(CommandMetaData.class)) { - throw new RuntimeException(MessageFormat.format("{0} must be annotated with {1}!", cmd.getName(), - CommandMetaData.class.getName())); - } - - UserModel user = getContext().getClient().getUser(); - CommandMetaData meta = cmd.getClass().getAnnotation(CommandMetaData.class); - if (meta.admin() && !user.canAdmin()) { - log.debug(MessageFormat.format("excluding admin command {0} for {1}", meta.name(), user.username)); - return; - } - commands.add(cmd.getClass()); - instantiated.add(cmd); - } - - private Map> getMap() { - if (map == null) { - map = Maps.newHashMapWithExpectedSize(commands.size()); - for (Class cmd : commands) { - CommandMetaData meta = cmd.getAnnotation(CommandMetaData.class); - if (map.containsKey(meta.name()) || aliasToCommand.containsKey(meta.name())) { - log.warn("{} already contains the \"{}\" command!", getName(), meta.name()); - } else { - map.put(meta.name(), cmd); - } - for (String alias : meta.aliases()) { - if (map.containsKey(alias) || aliasToCommand.containsKey(alias)) { - log.warn("{} already contains the \"{}\" command!", getName(), alias); - } else { - aliasToCommand.put(alias, meta.name()); - if (!commandToAliases.containsKey(meta.name())) { - commandToAliases.put(meta.name(), new ArrayList()); - } - commandToAliases.get(meta.name()).add(alias); - } - } - } - - for (Map.Entry entry : dispatchers.entrySet()) { - map.put(entry.getKey(), entry.getValue().getClass()); - } - } - return map; - } - - @Override - public void start(Environment env) throws IOException { - try { - parseCommandLine(); - if (Strings.isNullOrEmpty(commandName)) { - StringWriter msg = new StringWriter(); - msg.write(usage()); - throw new UnloggedFailure(1, msg.toString()); - } - - BaseCommand cmd = getCommand(); - if (getName().isEmpty()) { - cmd.setName(commandName); - } else { - cmd.setName(getName() + " " + commandName); - } - cmd.setArguments(args.toArray(new String[args.size()])); - - provideStateTo(cmd); - // atomicCmd.set(cmd); - cmd.start(env); - - } catch (UnloggedFailure e) { - String msg = e.getMessage(); - if (!msg.endsWith("\n")) { - msg += "\n"; - } - err.write(msg.getBytes(Charsets.UTF_8)); - err.flush(); - exit.onExit(e.exitCode); - } - } - - private BaseCommand getCommand() throws UnloggedFailure { - Map> map = getMap(); - String name = commandName; - if (aliasToCommand.containsKey(commandName)) { - name = aliasToCommand.get(name); - } - if (dispatchers.containsKey(name)) { - return dispatchers.get(name); - } - final Class c = map.get(name); - if (c == null) { - String msg = (getName().isEmpty() ? "Gitblit" : getName()) + ": " + commandName + ": not found"; - throw new UnloggedFailure(1, msg); - } - - for (BaseCommand cmd : instantiated) { - // use an already instantiated command - if (cmd.getClass().equals(c)) { - return cmd; - } - } - - BaseCommand cmd = null; - try { - cmd = c.newInstance(); - instantiated.add(cmd); - } catch (Exception e) { - throw new UnloggedFailure(1, MessageFormat.format("Failed to instantiate {0} command", commandName)); - } - return cmd; - } - - private boolean hasVisibleCommands() { - boolean visible = false; - for (Class cmd : commands) { - visible |= !cmd.getAnnotation(CommandMetaData.class).hidden(); - if (visible) { - return true; - } - } - for (DispatchCommand cmd : dispatchers.values()) { - visible |= cmd.hasVisibleCommands(); - if (visible) { - return true; - } - } - return false; - } - - public String getDescription() { - return getClass().getAnnotation(CommandMetaData.class).description(); - } - - @Override - public String usage() { - Set cmds = new TreeSet(); - Set dcs = new TreeSet(); - Map displayNames = Maps.newHashMap(); - int maxLength = -1; - Map> m = getMap(); - for (String name : m.keySet()) { - Class c = m.get(name); - CommandMetaData meta = c.getAnnotation(CommandMetaData.class); - if (meta.hidden()) { - continue; - } - - String displayName = name + (meta.admin() ? "*" : ""); - if (commandToAliases.containsKey(meta.name())) { - displayName = name + (meta.admin() ? "*" : "")+ " (" + Joiner.on(',').join(commandToAliases.get(meta.name())) + ")"; - } - displayNames.put(name, displayName); - - maxLength = Math.max(maxLength, displayName.length()); - if (DispatchCommand.class.isAssignableFrom(c)) { - DispatchCommand d = dispatchers.get(name); - if (d.hasVisibleCommands()) { - dcs.add(name); - } - } else { - cmds.add(name); - } - } - String format = "%-" + maxLength + "s %s"; - - final StringBuilder usage = new StringBuilder(); - if (!StringUtils.isEmpty(getName())) { - String title = getName().toUpperCase() + ": " + getDescription(); - String b = com.gitblit.utils.StringUtils.leftPad("", title.length() + 2, '═'); - usage.append('\n'); - usage.append(b).append('\n'); - usage.append(' ').append(title).append('\n'); - usage.append(b).append('\n'); - usage.append('\n'); - } - - if (!cmds.isEmpty()) { - usage.append("Available commands"); - if (!getName().isEmpty()) { - usage.append(" of "); - usage.append(getName()); - } - usage.append(" are:\n"); - usage.append("\n"); - for (String name : cmds) { - final Class c = m.get(name); - String displayName = displayNames.get(name); - CommandMetaData meta = c.getAnnotation(CommandMetaData.class); - usage.append(" "); - usage.append(String.format(format, displayName, Strings.nullToEmpty(meta.description()))); - usage.append("\n"); - } - usage.append("\n"); - } - - if (!dcs.isEmpty()) { - usage.append("Available command dispatchers"); - if (!getName().isEmpty()) { - usage.append(" of "); - usage.append(getName()); - } - usage.append(" are:\n"); - usage.append("\n"); - for (String name : dcs) { - final Class c = m.get(name); - String displayName = displayNames.get(name); - CommandMetaData meta = c.getAnnotation(CommandMetaData.class); - usage.append(" "); - usage.append(String.format(format, displayName, Strings.nullToEmpty(meta.description()))); - usage.append("\n"); - } - usage.append("\n"); - } - - usage.append("See '"); - if (!StringUtils.isEmpty(getName())) { - usage.append(getName()); - usage.append(' '); - } - usage.append("COMMAND --help' for more information.\n"); - usage.append("\n"); - return usage.toString(); - } + BaseCommand command = dispatchers.get(cmd); + if (command == null) { + try { + command = clazz.newInstance(); + instantiated.add(command); + } catch (Exception e) { + throw new UnloggedFailure(1, "fatal: failed to instantiate command"); + } + } + + return command; + } + + @Override + protected String getUsageText() { + if (getMap().isEmpty()) { + return ""; + } + + final StringBuilder sb = new StringBuilder(); + sb.append("COMMANDS\n"); + sb.append("────────\n"); + + TreeSet names = new TreeSet(getMap().keySet()); + for (String name : names) { + if (aliasToCommand.containsKey(name)) { + continue; + } + Class cmd = getMap().get(name); + CommandMetaData meta = cmd.getAnnotation(CommandMetaData.class); + List aliases = commandToAliases.get(name); + sb.append(" ").append(name); + if (aliases != null && !aliases.isEmpty()) { + sb.append(" (").append(Joiner.on(", ").join(aliases)).append(")"); + } + sb.append("\n ").append(meta.description()).append("\n\n"); + } + return sb.toString().trim(); + } } diff --git a/src/main/java/com/gitblit/transport/ssh/commands/SshCommandFactory.java b/src/main/java/com/gitblit/transport/ssh/commands/SshCommandFactory.java index fa4b91673..53340cf9b 100644 --- a/src/main/java/com/gitblit/transport/ssh/commands/SshCommandFactory.java +++ b/src/main/java/com/gitblit/transport/ssh/commands/SshCommandFactory.java @@ -28,12 +28,13 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; -import org.apache.sshd.server.Command; -import org.apache.sshd.server.CommandFactory; import org.apache.sshd.server.Environment; import org.apache.sshd.server.ExitCallback; -import org.apache.sshd.server.SessionAware; +import org.apache.sshd.server.channel.ChannelSession; +import org.apache.sshd.server.command.Command; +import org.apache.sshd.server.command.CommandFactory; import org.apache.sshd.server.session.ServerSession; +import org.apache.sshd.server.session.ServerSessionAware; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,229 +46,221 @@ import com.google.common.util.concurrent.ThreadFactoryBuilder; public class SshCommandFactory implements CommandFactory { - private static final Logger logger = LoggerFactory.getLogger(SshCommandFactory.class); + private static final Logger logger = LoggerFactory.getLogger(SshCommandFactory.class); - private final WorkQueue workQueue; - private final IGitblit gitblit; - private final ScheduledExecutorService startExecutor; - private final ExecutorService destroyExecutor; + private final WorkQueue workQueue; + private final IGitblit gitblit; + private final ScheduledExecutorService startExecutor; + private final ExecutorService destroyExecutor; - public SshCommandFactory(IGitblit gitblit, WorkQueue workQueue) { - this.gitblit = gitblit; - this.workQueue = workQueue; + public SshCommandFactory(IGitblit gitblit, WorkQueue workQueue) { + this.gitblit = gitblit; + this.workQueue = workQueue; - int threads = gitblit.getSettings().getInteger(Keys.git.sshCommandStartThreads, 2); - startExecutor = workQueue.createQueue(threads, "SshCommandStart"); - destroyExecutor = Executors.newSingleThreadExecutor( - new ThreadFactoryBuilder() - .setNameFormat("SshCommandDestroy-%s") - .setDaemon(true) - .build()); - } + int threads = gitblit.getSettings().getInteger(Keys.git.sshCommandStartThreads, 2); + startExecutor = workQueue.createQueue(threads, "SshCommandStart"); + destroyExecutor = Executors.newSingleThreadExecutor(new ThreadFactoryBuilder() + .setNameFormat("SshCommandDestroy-%s") + .setDaemon(true) + .build()); + } - public void stop() { - destroyExecutor.shutdownNow(); - } + public void stop() { + destroyExecutor.shutdownNow(); + } - public RootDispatcher createRootDispatcher(SshDaemonClient client, String commandLine) { - return new RootDispatcher(gitblit, client, commandLine, workQueue); - } + public RootDispatcher createRootDispatcher(SshDaemonClient client, String commandLine) { + return new RootDispatcher(gitblit, client, commandLine, workQueue); + } - @Override - public Command createCommand(final String commandLine) { - return new Trampoline(commandLine); - } + @Override + public Command createCommand(ChannelSession channel, String commandLine) throws IOException { + return new Trampoline(commandLine); + } - private class Trampoline implements Command, SessionAware { - private final String[] argv; - private ServerSession session; - private InputStream in; - private OutputStream out; - private OutputStream err; - private ExitCallback exit; - private Environment env; - private String cmdLine; - private DispatchCommand cmd; - private final AtomicBoolean logged; - private final AtomicReference> task; + private class Trampoline implements Command, ServerSessionAware { + private final String[] argv; + private ServerSession session; + private InputStream in; + private OutputStream out; + private OutputStream err; + private ExitCallback exit; + private Environment env; + private String cmdLine; + private DispatchCommand cmd; + private final AtomicBoolean logged; + private final AtomicReference> task; - Trampoline(String line) { - if (line.startsWith("git-")) { - line = "git " + line; - } - cmdLine = line; - argv = split(line); - logged = new AtomicBoolean(); - task = Atomics.newReference(); - } + Trampoline(String line) { + if (line.startsWith("git-")) { + line = "git " + line; + } + cmdLine = line; + argv = split(line); + logged = new AtomicBoolean(); + task = Atomics.newReference(); + } - @Override - public void setSession(ServerSession session) { - this.session = session; - } + @Override + public void setSession(ServerSession session) { + this.session = session; + } - @Override - public void setInputStream(final InputStream in) { - this.in = in; - } + @Override + public void setInputStream(InputStream in) { + this.in = in; + } - @Override - public void setOutputStream(final OutputStream out) { - this.out = out; - } + @Override + public void setOutputStream(OutputStream out) { + this.out = out; + } - @Override - public void setErrorStream(final OutputStream err) { - this.err = err; - } + @Override + public void setErrorStream(OutputStream err) { + this.err = err; + } - @Override - public void setExitCallback(final ExitCallback callback) { - this.exit = callback; - } + @Override + public void setExitCallback(ExitCallback callback) { + this.exit = callback; + } - @Override - public void start(final Environment env) throws IOException { - this.env = env; - task.set(startExecutor.submit(new Runnable() { - @Override - public void run() { - try { - onStart(); - } catch (Exception e) { - logger.warn("Cannot start command ", e); - } - } + @Override + public void start(ChannelSession channel, Environment env) throws IOException { + this.env = env; + task.set(startExecutor.submit(new Runnable() { + @Override + public void run() { + try { + onStart(); + } catch (Exception e) { + logger.warn("Cannot start command ", e); + } + } - @Override - public String toString() { - return "start (user " + session.getUsername() + ")"; - } - })); - } + @Override + public String toString() { + return "start (user " + session.getUsername() + ")"; + } + })); + } - private void onStart() throws IOException { - synchronized (this) { - SshDaemonClient client = session.getAttribute(SshDaemonClient.KEY); - try { - cmd = createRootDispatcher(client, cmdLine); - cmd.setArguments(argv); - cmd.setInputStream(in); - cmd.setOutputStream(out); - cmd.setErrorStream(err); - cmd.setExitCallback(new ExitCallback() { - @Override - public void onExit(int rc, String exitMessage) { - exit.onExit(translateExit(rc), exitMessage); - log(rc); - } + private void onStart() throws IOException { + synchronized (this) { + try { + final SshDaemonClient client = SshDaemonClient.get(session); + cmd = createRootDispatcher(client, cmdLine); + cmd.setArguments(argv); + cmd.setInputStream(in); + cmd.setOutputStream(out); + cmd.setErrorStream(err); + cmd.setExitCallback(new ExitCallback() { + @Override + public void onExit(int rc, String exitMessage, boolean closeImmediately) { + exit.onExit(translateExit(rc), exitMessage, closeImmediately); + log(rc); + } + }); + cmd.start(env); + } catch (RuntimeException e) { + throw e; + } + } + } - @Override - public void onExit(int rc) { - exit.onExit(translateExit(rc)); - log(rc); - } - }); - cmd.start(env); - } finally { - client = null; - } - } - } + private int translateExit(final int rc) { + switch (rc) { + case BaseCommand.STATUS_NOT_ADMIN: + return 1; + case BaseCommand.STATUS_CANCEL: + return 15; + case BaseCommand.STATUS_NOT_FOUND: + return 127; + default: + return rc; + } + } - private int translateExit(final int rc) { - switch (rc) { - case BaseCommand.STATUS_NOT_ADMIN: - return 1; + private void log(final int rc) { + if (logged.compareAndSet(false, true)) { + logger.info("onExecute: {} exits with: {}", cmd.getClass().getSimpleName(), rc); + } + } - case BaseCommand.STATUS_CANCEL: - return 15 /* SIGKILL */; + @Override + public void destroy(ChannelSession channel) { + Future future = task.getAndSet(null); + if (future != null) { + future.cancel(true); + destroyExecutor.execute(new Runnable() { + @Override + public void run() { + onDestroy(); + } + }); + } + } - case BaseCommand.STATUS_NOT_FOUND: - return 127 /* POSIX not found */; + private void onDestroy() { + synchronized (this) { + if (cmd != null) { + try { + cmd.destroy(); + } finally { + cmd = null; + } + } + } + } + } - default: - return rc; - } - } - - private void log(final int rc) { - if (logged.compareAndSet(false, true)) { - logger.info("onExecute: {} exits with: {}", cmd.getClass().getSimpleName(), rc); - } - } - - @Override - public void destroy() { - Future future = task.getAndSet(null); - if (future != null) { - future.cancel(true); - destroyExecutor.execute(new Runnable() { - @Override - public void run() { - onDestroy(); - } - }); - } - } - - private void onDestroy() { - synchronized (this) { - if (cmd != null) { - try { - cmd.destroy(); - } finally { - cmd = null; - } - } - } - } - } - - /** Split a command line into a string array. */ - static public String[] split(String commandLine) { - final List list = new ArrayList(); - boolean inquote = false; - boolean inDblQuote = false; - StringBuilder r = new StringBuilder(); - for (int ip = 0; ip < commandLine.length();) { - final char b = commandLine.charAt(ip++); - switch (b) { - case '\t': - case ' ': - if (inquote || inDblQuote) - r.append(b); - else if (r.length() > 0) { - list.add(r.toString()); - r = new StringBuilder(); - } - continue; - case '\"': - if (inquote) - r.append(b); - else - inDblQuote = !inDblQuote; - continue; - case '\'': - if (inDblQuote) - r.append(b); - else - inquote = !inquote; - continue; - case '\\': - if (inquote || ip == commandLine.length()) - r.append(b); // literal within a quote - else - r.append(commandLine.charAt(ip++)); - continue; - default: - r.append(b); - continue; - } - } - if (r.length() > 0) { - list.add(r.toString()); - } - return list.toArray(new String[list.size()]); - } + public static String[] split(String commandLine) { + final List list = new ArrayList(); + boolean inquote = false; + boolean inDblQuote = false; + StringBuilder r = new StringBuilder(); + for (int ip = 0; ip < commandLine.length();) { + final char b = commandLine.charAt(ip++); + switch (b) { + case '\t': + case ' ': + if (inquote || inDblQuote) { + r.append(b); + } else if (r.length() > 0) { + list.add(r.toString()); + r = new StringBuilder(); + } + continue; + case '"': + if (inquote) { + r.append(b); + } else { + inDblQuote = !inDblQuote; + } + continue; + case '\'': + if (inDblQuote) { + r.append(b); + } else { + inquote = !inquote; + } + continue; + case '\\': + if (inquote || ip == commandLine.length()) { + r.append(b); + } else { + r.append(commandLine.charAt(ip++)); + } + continue; + default: + r.append(b); + continue; + } + } + if (r.length() > 0) { + list.add(r.toString()); + } + return list.toArray(new String[list.size()]); + } } diff --git a/src/test/java/com/gitblit/tests/GitBlitSuite.java b/src/test/java/com/gitblit/tests/GitBlitSuite.java index e0328112a..698b066cc 100644 --- a/src/test/java/com/gitblit/tests/GitBlitSuite.java +++ b/src/test/java/com/gitblit/tests/GitBlitSuite.java @@ -17,12 +17,14 @@ import java.io.File; import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.lang.reflect.Field; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; +import java.io.FileOutputStream; +import java.io.IOException; +import java.lang.reflect.Field; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -110,10 +112,11 @@ public class GitBlitSuite { public static String gitServletUrl = "http://localhost:" + port + "/git"; public static String gitDaemonUrl = "git://localhost:" + gitPort; public static String sshDaemonUrl = "ssh://admin@localhost:" + sshPort; - public static String account = "admin"; - public static String password = "admin"; - - private static AtomicBoolean started = new AtomicBoolean(false); + public static String account = "admin"; + public static String password = "admin"; + + private static AtomicBoolean started = new AtomicBoolean(false); + private static AtomicBoolean repositoriesInitialized = new AtomicBoolean(false); public static Repository getHelloworldRepository() { return getRepository("helloworld.git"); @@ -151,13 +154,13 @@ private static Repository getRepository(String name) { return null; } - public static boolean startGitblit() throws Exception { - if (started.get()) { - // already started - return false; - } - - GitServletTest.deleteWorkingFolders(); + public static boolean startGitblit() throws Exception { + if (started.get()) { + ensureTestRepositories(); + return false; + } + + GitServletTest.deleteWorkingFolders(); // Start a Gitblit instance Executors.newSingleThreadExecutor().execute(new Runnable() { @@ -174,22 +177,24 @@ public void run() { "--settings", GitBlitSuite.SETTINGS.getAbsolutePath(), "--baseFolder", "data"); } - }); - - // Wait a few seconds for it to be running - Thread.sleep(5000); - - started.set(true); - return true; - } - - public static void stopGitblit() throws Exception { - // Stop Gitblit - GitBlitServer.main("--stop", "--shutdownPort", "" + shutdownPort); - - // Wait a few seconds for it to be running - Thread.sleep(5000); - } + }); + + waitForPort("http", port); + waitForPort("ssh", sshPort); + + started.set(true); + ensureTestRepositories(); + return true; + } + + public static void stopGitblit() throws Exception { + // Stop Gitblit + GitBlitServer.main("--stop", "--shutdownPort", "" + shutdownPort); + + // Wait a few seconds for it to be running + Thread.sleep(5000); + started.set(false); + } public static void deleteRefChecksFolder() throws IOException { File refChecks = new File(GitBlitSuite.REPOSITORIES, "refchecks"); @@ -198,46 +203,20 @@ public static void deleteRefChecksFolder() throws IOException { } } - @BeforeClass - public static void setUp() throws Exception { - //"refchecks" folder is used in GitServletTest; - //need be deleted before Gitblit server instance is started - deleteRefChecksFolder(); - startGitblit(); - - if (REPOSITORIES.exists() || REPOSITORIES.mkdirs()) { - if (!HELLOWORLD_REPO_SOURCE.exists()) { - unzipRepository(HELLOWORLD_REPO_SOURCE.getPath() + ".zip", HELLOWORLD_REPO_SOURCE.getParentFile()); - } - if (!TICGIT_REPO_SOURCE.exists()) { - unzipRepository(TICGIT_REPO_SOURCE.getPath() + ".zip", TICGIT_REPO_SOURCE.getParentFile()); - } - if (!AMBITION_REPO_SOURCE.exists()) { - unzipRepository(AMBITION_REPO_SOURCE.getPath() + ".zip", AMBITION_REPO_SOURCE.getParentFile()); - } - if (!GITECTIVE_REPO_SOURCE.exists()) { - unzipRepository(GITECTIVE_REPO_SOURCE.getPath() + ".zip", GITECTIVE_REPO_SOURCE.getParentFile()); - } - cloneOrFetch("helloworld.git", HELLOWORLD_REPO_SOURCE.getAbsolutePath()); - cloneOrFetch("ticgit.git", TICGIT_REPO_SOURCE.getAbsolutePath()); - cloneOrFetch("test/jgit.git", "https://github.com/eclipse-jgit/jgit.git"); - cloneOrFetch("test/helloworld.git", HELLOWORLD_REPO_SOURCE.getAbsolutePath()); - cloneOrFetch("test/ambition.git", AMBITION_REPO_SOURCE.getAbsolutePath()); - cloneOrFetch("test/gitective.git", GITECTIVE_REPO_SOURCE.getAbsolutePath()); - - showRemoteBranches("ticgit.git"); - automaticallyTagBranchTips("ticgit.git"); - showRemoteBranches("test/jgit.git"); - automaticallyTagBranchTips("test/jgit.git"); - } - } + @BeforeClass + public static void setUp() throws Exception { + //"refchecks" folder is used in GitServletTest; + //need be deleted before Gitblit server instance is started + deleteRefChecksFolder(); + startGitblit(); + } @AfterClass public static void tearDown() throws Exception { stopGitblit(); } - private static void cloneOrFetch(String name, String fromUrl) throws Exception { + private static void cloneOrFetch(String name, String fromUrl) throws Exception { System.out.print("Fetching " + name + "... "); try { JGitUtils.cloneRepository(REPOSITORIES, name, fromUrl); @@ -245,7 +224,57 @@ private static void cloneOrFetch(String name, String fromUrl) throws Exception { System.out.println("Error: " + t.getMessage()); } System.out.println("done."); - } + } + + private static void ensureTestRepositories() throws Exception { + if (repositoriesInitialized.get()) { + return; + } + synchronized (repositoriesInitialized) { + if (repositoriesInitialized.get()) { + return; + } + if (REPOSITORIES.exists() || REPOSITORIES.mkdirs()) { + if (!HELLOWORLD_REPO_SOURCE.exists()) { + unzipRepository(HELLOWORLD_REPO_SOURCE.getPath() + ".zip", HELLOWORLD_REPO_SOURCE.getParentFile()); + } + if (!TICGIT_REPO_SOURCE.exists()) { + unzipRepository(TICGIT_REPO_SOURCE.getPath() + ".zip", TICGIT_REPO_SOURCE.getParentFile()); + } + if (!AMBITION_REPO_SOURCE.exists()) { + unzipRepository(AMBITION_REPO_SOURCE.getPath() + ".zip", AMBITION_REPO_SOURCE.getParentFile()); + } + if (!GITECTIVE_REPO_SOURCE.exists()) { + unzipRepository(GITECTIVE_REPO_SOURCE.getPath() + ".zip", GITECTIVE_REPO_SOURCE.getParentFile()); + } + cloneOrFetch("helloworld.git", HELLOWORLD_REPO_SOURCE.getAbsolutePath()); + cloneOrFetch("ticgit.git", TICGIT_REPO_SOURCE.getAbsolutePath()); + cloneOrFetch("test/jgit.git", "https://github.com/eclipse-jgit/jgit.git"); + cloneOrFetch("test/helloworld.git", HELLOWORLD_REPO_SOURCE.getAbsolutePath()); + cloneOrFetch("test/ambition.git", AMBITION_REPO_SOURCE.getAbsolutePath()); + cloneOrFetch("test/gitective.git", GITECTIVE_REPO_SOURCE.getAbsolutePath()); + + showRemoteBranches("ticgit.git"); + automaticallyTagBranchTips("ticgit.git"); + showRemoteBranches("test/jgit.git"); + automaticallyTagBranchTips("test/jgit.git"); + } + repositoriesInitialized.set(true); + } + } + + private static void waitForPort(String service, int port) throws Exception { + long deadline = System.currentTimeMillis() + 30000L; + while (System.currentTimeMillis() < deadline) { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress("127.0.0.1", port), 500); + return; + } catch (IOException e) { + Thread.sleep(250L); + } + } + throw new IOException("Timed out waiting for " + service + " port " + port); + } private static void showRemoteBranches(String repositoryName) { try { diff --git a/src/test/java/com/gitblit/tests/SshDaemonTest.java b/src/test/java/com/gitblit/tests/SshDaemonTest.java index e88dc9bbf..0af96a831 100644 --- a/src/test/java/com/gitblit/tests/SshDaemonTest.java +++ b/src/test/java/com/gitblit/tests/SshDaemonTest.java @@ -17,12 +17,16 @@ import java.io.File; import java.security.KeyPair; +import java.util.Arrays; import java.text.MessageFormat; import java.util.List; import org.apache.sshd.client.SshClient; import org.apache.sshd.client.future.AuthFuture; import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.NamedFactory; +import org.apache.sshd.common.signature.BuiltinSignatures; +import org.apache.sshd.common.signature.Signature; import org.eclipse.jgit.api.CloneCommand; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.revwalk.RevCommit; @@ -65,6 +69,16 @@ public void testPublicKeyAuthentication() throws Exception { assertTrue(authFuture.isSuccess()); } + @Test + public void testPublicKeyAuthenticationRsaSha256() throws Exception { + assertPublicKeyAuthenticationWithSignature(BuiltinSignatures.rsaSHA256, "rsa-sha2-256"); + } + + @Test + public void testPublicKeyAuthenticationRsaSha512() throws Exception { + assertPublicKeyAuthenticationWithSignature(BuiltinSignatures.rsaSHA512, "rsa-sha2-512"); + } + @Test public void testWrongPublicKeyAuthentication() throws Exception { SshClient client = getClient(); @@ -151,4 +165,22 @@ public void testCloneCommand() throws Exception { model.authorizationControl = AuthorizationControl.NAMED; repositories().updateRepositoryModel(model.name, model, false); } + + private void assertPublicKeyAuthenticationWithSignature(BuiltinSignatures signatureFactory, String signatureName) + throws Exception { + SignatureCaptureReporter reporter = new SignatureCaptureReporter(); + List> signatureFactories = Arrays.>asList(signatureFactory); + SshClient client = getClient(signatureFactories, reporter); + ClientSession session = client.connect(username, "localhost", GitBlitSuite.sshPort).verify().getSession(); + + session.addPublicKeyIdentity(rwKeyPair); + AuthFuture authFuture = session.auth(); + assertTrue(authFuture.await()); + assertTrue(authFuture.isSuccess()); + assertTrue("client did not attempt " + signatureName, + reporter.getAttempts().contains(signatureName) || reporter.getSignatures().contains(signatureName)); + assertFalse("client fell back to ssh-rsa", + reporter.getAttempts().contains("ssh-rsa") || reporter.getSignatures().contains("ssh-rsa")); + client.stop(); + } } diff --git a/src/test/java/com/gitblit/tests/SshUnitTest.java b/src/test/java/com/gitblit/tests/SshUnitTest.java index acb0269c2..bdef7f534 100644 --- a/src/test/java/com/gitblit/tests/SshUnitTest.java +++ b/src/test/java/com/gitblit/tests/SshUnitTest.java @@ -25,17 +25,25 @@ import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.PublicKey; +import java.util.ArrayList; +import java.util.Collections; import java.util.EnumSet; +import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import org.apache.sshd.client.SshClient; +import org.apache.sshd.client.auth.pubkey.PublicKeyAuthenticationReporter; import org.apache.sshd.client.channel.ClientChannel; import org.apache.sshd.client.channel.ClientChannelEvent; import org.apache.sshd.client.config.keys.ClientIdentityLoader; import org.apache.sshd.client.future.AuthFuture; import org.apache.sshd.client.keyverifier.ServerKeyVerifier; import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.NamedFactory; import org.apache.sshd.common.config.keys.FilePasswordProvider; +import org.apache.sshd.common.NamedResource; +import org.apache.sshd.common.session.SessionContext; +import org.apache.sshd.common.signature.Signature; import org.apache.sshd.common.util.security.SecurityUtils; import org.eclipse.jgit.lib.Config; import org.eclipse.jgit.storage.file.FileBasedConfig; @@ -45,6 +53,7 @@ import org.junit.AfterClass; import org.junit.Before; import org.junit.BeforeClass; +import org.junit.Ignore; import com.gitblit.Constants.AccessPermission; import com.gitblit.transport.ssh.IPublicKeyManager; @@ -54,6 +63,7 @@ /** * Base class for SSH unit tests. */ +@Ignore("abstract ssh test base") public abstract class SshUnitTest extends GitblitUnitTest { protected static final AtomicBoolean started = new AtomicBoolean(false); @@ -156,15 +166,28 @@ public void tearDown() { } protected SshClient getClient() { + return getClient(null, null); + } + + protected SshClient getClient(List> signatureFactories, + PublicKeyAuthenticationReporter publicKeyAuthenticationReporter) { SshClient client = SshClient.setUpDefaultClient(); + if (signatureFactories != null) { + client.setSignatureFactories(signatureFactories); + } + if (publicKeyAuthenticationReporter != null) { + client.setPublicKeyAuthenticationReporter(publicKeyAuthenticationReporter); + } client.setClientIdentityLoader(new ClientIdentityLoader() { // Ignore the files under ~/.ssh @Override - public boolean isValidLocation(String location) throws IOException { - return true; + public boolean isValidLocation(NamedResource location) throws IOException { + return false; } + @Override - public KeyPair loadClientIdentity(String location, FilePasswordProvider provider) throws IOException, GeneralSecurityException { - return null; + public Iterable loadClientIdentities(SessionContext session, NamedResource location, FilePasswordProvider provider) + throws IOException, GeneralSecurityException { + return Collections.emptyList(); } }); client.setServerKeyVerifier(new ServerKeyVerifier() { @@ -177,6 +200,48 @@ public boolean verifyServerKey(ClientSession sshClientSession, SocketAddress rem return client; } + protected static class SignatureCaptureReporter implements PublicKeyAuthenticationReporter { + private final List attempts = new ArrayList(); + private final List signatures = new ArrayList(); + + @Override + public void signalAuthenticationAttempt(ClientSession session, String service, KeyPair identity, String signature) + throws Exception { + attempts.add(signature); + } + + @Override + public void signalAuthenticationExhausted(ClientSession session, String service) throws Exception { + } + + @Override + public void signalIdentitySkipped(ClientSession session, String service, KeyPair identity) throws Exception { + } + + @Override + public void signalSignatureAttempt(ClientSession session, String service, KeyPair identity, String signature, + byte[] signedData) throws Exception { + signatures.add(signature); + } + + @Override + public void signalAuthenticationSuccess(ClientSession session, String service, KeyPair identity) throws Exception { + } + + @Override + public void signalAuthenticationFailure(ClientSession session, String service, KeyPair identity, boolean partial, + List serverMethods) throws Exception { + } + + public List getAttempts() { + return attempts; + } + + public List getSignatures() { + return signatures; + } + } + protected String testSshCommand(String cmd) throws IOException, InterruptedException { return testSshCommand(cmd, null); } @@ -202,9 +267,8 @@ protected String testSshCommand(String cmd, String stdin) throws IOException, In ByteArrayOutputStream err = new ByteArrayOutputStream(); channel.setOut(out); channel.setErr(err); - channel.open(); - - channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED, ClientChannelEvent.EOF), 0); + channel.open().verify(); + channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), 30000); String result = out.toString().trim(); channel.close(false);