From ca3390726c46c3d96102fa67d1ce862b32da843a Mon Sep 17 00:00:00 2001 From: Dmitri Khokhlov Date: Fri, 5 Jun 2026 18:53:08 -0700 Subject: [PATCH 1/2] Upgrade SSH transport to MINA SSHD 2.14 Add server-sig-algs support so modern OpenSSH clients can negotiate RSA-SHA2 signatures successfully, and adapt the Gitblit SSH command/session integrations to the SSHD 2.x APIs. Also fix publickey auth session handling so the two-step PK_OK flow used by newer SSHD/OpenSSH combinations does not reject the signed follow-up request after the key has already been accepted. --- build.moxie | 4 +- .../ssh/DisabledFilesystemFactory.java | 19 +- .../transport/ssh/FileKeyPairProvider.java | 176 +--- .../gitblit/transport/ssh/LdapKeyManager.java | 656 ++++++------- .../ssh/ServerSigAlgsExtensionHandler.java | 118 +++ .../com/gitblit/transport/ssh/SshDaemon.java | 386 +++----- .../transport/ssh/SshDaemonClient.java | 110 ++- .../transport/ssh/SshKeyAuthenticator.java | 86 +- .../ssh/SshServerSessionFactory.java | 71 +- .../gitblit/transport/ssh/WelcomeShell.java | 365 ++++--- .../transport/ssh/commands/BaseCommand.java | 899 ++++++++---------- .../ssh/commands/DispatchCommand.java | 631 +++++------- .../ssh/commands/SshCommandFactory.java | 407 ++++---- 13 files changed, 1725 insertions(+), 2203 deletions(-) create mode 100644 src/main/java/com/gitblit/transport/ssh/ServerSigAlgsExtensionHandler.java 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..f5777010f 100644 --- a/src/main/java/com/gitblit/transport/ssh/FileKeyPairProvider.java +++ b/src/main/java/com/gitblit/transport/ssh/FileKeyPairProvider.java @@ -1,126 +1,87 @@ /* - * 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; import java.io.File; import java.io.FileInputStream; import java.io.InputStreamReader; -import java.security.KeyFactory; import java.security.KeyPair; -import java.security.PrivateKey; -import java.security.PublicKey; import java.util.Arrays; import java.util.Iterator; import java.util.NoSuchElementException; -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; import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; -import org.bouncycastle.crypto.params.AsymmetricKeyParameter; -import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; -import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; -import org.bouncycastle.crypto.util.OpenSSHPrivateKeyUtil; -import org.bouncycastle.crypto.util.OpenSSHPublicKeyUtil; -import org.bouncycastle.jcajce.spec.OpenSSHPrivateKeySpec; -import org.bouncycastle.jcajce.spec.OpenSSHPublicKeySpec; -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; } - @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 +97,31 @@ private boolean setNextObject() } return false; } - }; } }; } - - 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. - 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)); - - 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); - } - 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. - byte[] privateKeyContent = pemObject.getContent(); - PrivateKeyEntryDecoder decoder = SecurityUtils.getOpenSSHEDDSAPrivateKeyEntryDecoder(); - PrivateKey privateKey = decoder.decodePrivateKey(null, privateKeyContent, 0, privateKeyContent.length); - PublicKey publicKey = SecurityUtils. recoverEDDSAPublicKey(privateKey); - return new KeyPair(publicKey, privateKey); - } + private KeyPair doLoadKey(String file) { + try (PEMParser r = new PEMParser(new InputStreamReader(new FileInputStream(file)))) { + Object o = r.readObject(); + if (o == null) { + return null; } - 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; - } - else if (o instanceof KeyPair) { - return (KeyPair)o; - } - else { - log.warn("Cannot read unsupported PEM object of type: " + o.getClass().getCanonicalName()); - } + JcaPEMKeyConverter pemConverter = new JcaPEMKeyConverter(); + pemConverter.setProvider("BC"); + if (o instanceof PEMKeyPair) { + return pemConverter.getKeyPair((PEMKeyPair) o); + } + if (o instanceof KeyPair) { + return (KeyPair) o; } - } - catch (Exception e) { - log.warn("Unable to read key " + file, e); + log.warn("Cannot read unsupported PEM object of type: {}", o.getClass().getCanonicalName()); + } 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..ae7521500 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; @@ -23,27 +22,17 @@ import java.net.InetSocketAddress; import java.security.KeyPair; import java.security.KeyPairGenerator; -import java.security.PrivateKey; import java.text.MessageFormat; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; -import net.i2p.crypto.eddsa.EdDSAPrivateKey; -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.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; import org.bouncycastle.openssl.jcajce.JcaMiscPEMGenerator; -import org.bouncycastle.util.io.pem.PemObject; import org.bouncycastle.util.io.pem.PemWriter; import org.eclipse.jgit.internal.JGitText; import org.slf4j.Logger; @@ -59,264 +48,179 @@ 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 final IGitblit gitblit; - private final SshServer sshd; + private static final Logger log = LoggerFactory.getLogger(SshDaemon.class); - /** - * Construct the Gitblit SSH daemon. - * - * @param gitblit - * @param workQueue - */ - public SshDaemon(IGitblit gitblit, WorkQueue workQueue) { - this.gitblit = gitblit; + 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"; - 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"); + } - // 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"); + generateKeyPair(rsaKeyStore, "RSA", 2048); + generateKeyPair(ecdsaKeyStore, "ECDSA", 256); + FileKeyPairProvider hostKeyPairProvider = new FileKeyPairProvider(); + hostKeyPairProvider.setFiles(new String[] { + ecdsaKeyStore.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; - } + protected void generateKeyPair(File file, String algorithm, int keySize) { + if (file.exists()) { + return; + } try { - KeyPairGenerator generator = SecurityUtils.getKeyPairGenerator(algorithm); - if (keySize != 0) { - generator.initialize(keySize); - log.info("Generating {}-{} SSH host keypair...", algorithm, keySize); - } else { - log.info("Generating {} SSH host keypair...", algorithm); + log.info(keySize > 0 ? "Generating {}-{} SSH host keypair..." : "Generating {} SSH host keypair...", + algorithm, Integer.valueOf(keySize)); + + KeyPairGenerator generator = KeyPairGenerator.getInstance(algorithm); + if (keySize > 0) { + generator.initialize(keySize); } 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); - } catch (UnsatisfiedLinkError | UnsupportedOperationException e) { - // Unexpected/Unsupported OS or Architecture + Files.createParentDirs(file); + try (FileOutputStream fos = new FileOutputStream(file)) { + try (PemWriter writer = new PemWriter(new OutputStreamWriter(fos))) { + writer.writeObject(new JcaMiscPEMGenerator(kp)); + } } - - 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(); + JnaUtils.setFilemode(file, JnaUtils.S_IFREG | 0400); } 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..df5e00834 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,257 @@ 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())); - } - - 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()); - } - } - - /** - * 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(); - } + 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 { + String cmd = commandName; + if (aliasToCommand.containsKey(cmd)) { + cmd = aliasToCommand.get(cmd); + } + + Class clazz = getMap().get(cmd); + if (clazz == null) { + throw new UnloggedFailure(STATUS_NOT_FOUND, "fatal: '" + commandName + "' is not a registered command"); + } + + 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()]); + } } From 67878b264997ff7812de11d7983756f8056e6d7f Mon Sep 17 00:00:00 2001 From: Dmitri Khokhlov Date: Fri, 5 Jun 2026 20:12:35 -0700 Subject: [PATCH 2/2] Fix SSH RSA-SHA2 test harness and auth flow Upgrade the SSH stack to modern MINA SSHD, restore Ed25519/EdDSA key handling, and fix the test harness so SSH integration tests reliably start and reuse the embedded server. Also repair SSH command alias resolution and channel lifecycle handling exposed by the newer client/server behavior. --- .../transport/ssh/FileKeyPairProvider.java | 78 +++++++-- .../com/gitblit/transport/ssh/SshDaemon.java | 70 ++++++-- .../ssh/commands/DispatchCommand.java | 21 +-- .../java/com/gitblit/tests/GitBlitSuite.java | 165 ++++++++++-------- .../java/com/gitblit/tests/SshDaemonTest.java | 32 ++++ .../java/com/gitblit/tests/SshUnitTest.java | 78 ++++++++- 6 files changed, 335 insertions(+), 109 deletions(-) diff --git a/src/main/java/com/gitblit/transport/ssh/FileKeyPairProvider.java b/src/main/java/com/gitblit/transport/ssh/FileKeyPairProvider.java index f5777010f..336a48dcd 100644 --- a/src/main/java/com/gitblit/transport/ssh/FileKeyPairProvider.java +++ b/src/main/java/com/gitblit/transport/ssh/FileKeyPairProvider.java @@ -18,17 +18,31 @@ import java.io.File; import java.io.FileInputStream; import java.io.InputStreamReader; +import java.security.KeyFactory; import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.PublicKey; import java.util.Arrays; 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; import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.bouncycastle.crypto.params.AsymmetricKeyParameter; +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; +import org.bouncycastle.crypto.util.OpenSSHPrivateKeyUtil; +import org.bouncycastle.crypto.util.OpenSSHPublicKeyUtil; +import org.bouncycastle.jcajce.spec.OpenSSHPrivateKeySpec; +import org.bouncycastle.jcajce.spec.OpenSSHPublicKeySpec; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemReader; public class FileKeyPairProvider extends AbstractKeyPairProvider { @@ -49,6 +63,10 @@ public void setFiles(String[] files) { this.files = files; } + public Iterable loadKeys() { + return loadKeys(null); + } + @Override public Iterable loadKeys(SessionContext session) { if (!SecurityUtils.isBouncyCastleRegistered()) { @@ -103,22 +121,56 @@ private boolean setNextObject() { } private KeyPair doLoadKey(String file) { - try (PEMParser r = new PEMParser(new InputStreamReader(new FileInputStream(file)))) { - Object o = r.readObject(); - if (o == null) { - return null; + try { + try (PemReader r = new PemReader(new InputStreamReader(new FileInputStream(file)))) { + PemObject pemObject = r.readPemObject(); + 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)); + KeyFactory kf = KeyFactory.getInstance("Ed25519", "BC"); + PrivateKey privateKey = kf.generatePrivate(privkeySpec); + PublicKey publicKey = kf.generatePublic(pubKeySpec); + return new KeyPair(publicKey, privateKey); + } + 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 (pemObject != null && "EDDSA PRIVATE KEY".equals(pemObject.getType())) { + byte[] privateKeyContent = pemObject.getContent(); + 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); + } } - JcaPEMKeyConverter pemConverter = new JcaPEMKeyConverter(); - pemConverter.setProvider("BC"); - if (o instanceof PEMKeyPair) { - return pemConverter.getKeyPair((PEMKeyPair) o); - } - if (o instanceof KeyPair) { - return (KeyPair) o; + 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) { + return pemConverter.getKeyPair((PEMKeyPair) o); + } + if (o instanceof KeyPair) { + return (KeyPair) o; + } + if (o != null) { + log.warn("Cannot read unsupported PEM object of type: {}", o.getClass().getCanonicalName()); + } } - - log.warn("Cannot read unsupported PEM object of type: {}", o.getClass().getCanonicalName()); } catch (Exception e) { log.warn("Unable to read key {}", file, e); } diff --git a/src/main/java/com/gitblit/transport/ssh/SshDaemon.java b/src/main/java/com/gitblit/transport/ssh/SshDaemon.java index ae7521500..9b14edf7c 100644 --- a/src/main/java/com/gitblit/transport/ssh/SshDaemon.java +++ b/src/main/java/com/gitblit/transport/ssh/SshDaemon.java @@ -22,17 +22,28 @@ import java.net.InetSocketAddress; import java.security.KeyPair; import java.security.KeyPairGenerator; +import java.security.PrivateKey; import java.text.MessageFormat; import java.util.List; 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.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.bouncycastle.crypto.params.AsymmetricKeyParameter; +import org.bouncycastle.crypto.util.OpenSSHPrivateKeyUtil; +import org.bouncycastle.crypto.util.PrivateKeyFactory; import org.bouncycastle.openssl.jcajce.JcaMiscPEMGenerator; +import org.bouncycastle.util.io.pem.PemObject; import org.bouncycastle.util.io.pem.PemWriter; import org.eclipse.jgit.internal.JGitText; import org.slf4j.Logger; @@ -76,15 +87,24 @@ public SshDaemon(IGitblit gitblit, WorkQueue workQueue) { 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"); + } 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() }); @@ -199,26 +219,54 @@ public synchronized void stop() { } } - protected void generateKeyPair(File file, String algorithm, int keySize) { + static void generateKeyPair(File file, String algorithm, int keySize) { if (file.exists()) { return; } try { - log.info(keySize > 0 ? "Generating {}-{} SSH host keypair..." : "Generating {} SSH host keypair...", - algorithm, Integer.valueOf(keySize)); - - KeyPairGenerator generator = KeyPairGenerator.getInstance(algorithm); - if (keySize > 0) { + KeyPairGenerator generator = SecurityUtils.getKeyPairGenerator(algorithm); + if (keySize != 0) { 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(); - Files.createParentDirs(file); - try (FileOutputStream fos = new FileOutputStream(file)) { - try (PemWriter writer = new PemWriter(new OutputStreamWriter(fos))) { - writer.writeObject(new JcaMiscPEMGenerator(kp)); + + Files.touch(file); + try { + JnaUtils.setFilemode(file, JnaUtils.S_IRUSR | JnaUtils.S_IWUSR); + } catch (UnsatisfiedLinkError | UnsupportedOperationException e) { + // Unsupported platform-specific chmod path. + } + + 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)); + } } } - JnaUtils.setFilemode(file, JnaUtils.S_IFREG | 0400); } catch (Exception e) { log.error("Unable to generate " + algorithm + " keypair", e); } 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 df5e00834..e9cf01afd 100644 --- a/src/main/java/com/gitblit/transport/ssh/commands/DispatchCommand.java +++ b/src/main/java/com/gitblit/transport/ssh/commands/DispatchCommand.java @@ -244,16 +244,17 @@ public void start(Environment env) throws IOException { } } - protected BaseCommand getCommand() throws UnloggedFailure { - String cmd = commandName; - if (aliasToCommand.containsKey(cmd)) { - cmd = aliasToCommand.get(cmd); - } - - Class clazz = getMap().get(cmd); - if (clazz == null) { - throw new UnloggedFailure(STATUS_NOT_FOUND, "fatal: '" + commandName + "' is not a registered command"); - } + protected BaseCommand getCommand() throws UnloggedFailure { + Map> commandMap = getMap(); + String cmd = commandName; + if (aliasToCommand.containsKey(cmd)) { + cmd = aliasToCommand.get(cmd); + } + + Class clazz = commandMap.get(cmd); + if (clazz == null) { + throw new UnloggedFailure(STATUS_NOT_FOUND, "fatal: '" + commandName + "' is not a registered command"); + } BaseCommand command = dispatchers.get(cmd); if (command == null) { 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);