1   /*
2    * Copyright 2017 LINE Corporation
3    *
4    * LINE Corporation licenses this file to you under the Apache License,
5    * version 2.0 (the "License"); you may not use this file except in compliance
6    * with the License. You may obtain a copy of the License at:
7    *
8    *   https://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13   * License for the specific language governing permissions and limitations
14   * under the License.
15   */
16  
17  package com.linecorp.centraldogma.server.internal.mirror;
18  
19  import static com.linecorp.centraldogma.server.internal.mirror.credential.PublicKeyMirrorCredential.publicKeyPreview;
20  
21  import java.io.File;
22  import java.io.IOException;
23  import java.net.URI;
24  import java.net.URISyntaxException;
25  import java.security.GeneralSecurityException;
26  import java.security.KeyPair;
27  import java.util.Collection;
28  
29  import javax.annotation.Nullable;
30  
31  import org.apache.sshd.client.ClientBuilder;
32  import org.apache.sshd.client.SshClient;
33  import org.apache.sshd.client.config.hosts.HostConfigEntryResolver;
34  import org.apache.sshd.client.session.ClientSession;
35  import org.apache.sshd.common.NamedResource;
36  import org.apache.sshd.common.config.keys.FilePasswordProvider;
37  import org.apache.sshd.common.config.keys.loader.KeyPairResourceParser;
38  import org.apache.sshd.common.config.keys.loader.openssh.OpenSSHKeyPairResourceParser;
39  import org.apache.sshd.common.config.keys.loader.pem.PKCS8PEMResourceKeyPairParser;
40  import org.apache.sshd.common.file.nonefs.NoneFileSystemFactory;
41  import org.apache.sshd.common.keyprovider.KeyIdentityProvider;
42  import org.apache.sshd.common.util.security.SecurityUtils;
43  import org.apache.sshd.common.util.security.bouncycastle.BouncyCastleRandom;
44  import org.apache.sshd.git.GitModuleProperties;
45  import org.apache.sshd.git.transport.GitSshdSessionFactory;
46  import org.eclipse.jgit.api.TransportCommand;
47  import org.eclipse.jgit.errors.UnsupportedCredentialItem;
48  import org.eclipse.jgit.transport.CredentialItem;
49  import org.eclipse.jgit.transport.CredentialsProvider;
50  import org.eclipse.jgit.transport.SshTransport;
51  import org.eclipse.jgit.transport.URIish;
52  import org.slf4j.Logger;
53  import org.slf4j.LoggerFactory;
54  
55  import com.cronutils.model.Cron;
56  import com.google.common.annotations.VisibleForTesting;
57  
58  import com.linecorp.centraldogma.server.MirrorException;
59  import com.linecorp.centraldogma.server.command.CommandExecutor;
60  import com.linecorp.centraldogma.server.internal.mirror.credential.PasswordMirrorCredential;
61  import com.linecorp.centraldogma.server.internal.mirror.credential.PublicKeyMirrorCredential;
62  import com.linecorp.centraldogma.server.mirror.MirrorCredential;
63  import com.linecorp.centraldogma.server.mirror.MirrorDirection;
64  import com.linecorp.centraldogma.server.storage.repository.Repository;
65  
66  final class SshGitMirror extends AbstractGitMirror {
67  
68      private static final Logger logger = LoggerFactory.getLogger(SshGitMirror.class);
69  
70      private static final KeyPairResourceParser keyPairResourceParser = KeyPairResourceParser.aggregate(
71              // Use BouncyCastle resource parser to support non-standard formats as well.
72              SecurityUtils.getBouncycastleKeyPairResourceParser(),
73              PKCS8PEMResourceKeyPairParser.INSTANCE,
74              OpenSSHKeyPairResourceParser.INSTANCE);
75  
76      // Creates BouncyCastleRandom once and reuses it.
77      // Otherwise, BouncyCastleRandom is created whenever the SSH client is created that leads to
78      // blocking the thread to get enough entropy for SecureRandom.
79      // We might create multiple BouncyCastleRandom later and poll them, if necessary.
80      private static final BouncyCastleRandom bounceCastleRandom = new BouncyCastleRandom();
81  
82      SshGitMirror(Cron schedule, MirrorDirection direction, MirrorCredential credential,
83                   Repository localRepo, String localPath,
84                   URI remoteRepoUri, String remotePath, @Nullable String remoteBranch,
85                   @Nullable String gitignore) {
86          super(schedule, direction, credential, localRepo, localPath, remoteRepoUri, remotePath, remoteBranch,
87                gitignore);
88      }
89  
90      @Override
91      protected void mirrorLocalToRemote(File workDir, int maxNumFiles, long maxNumBytes) throws Exception {
92          final URIish remoteUri = remoteUri();
93          try (SshClient sshClient = createSshClient();
94               ClientSession session = createSession(sshClient, remoteUri)) {
95              final DefaultGitSshdSessionFactory sessionFactory =
96                      new DefaultGitSshdSessionFactory(sshClient, session);
97              try (GitWithAuth git = openGit(workDir, remoteUri, sessionFactory::configureCommand)) {
98                  mirrorLocalToRemote(git, maxNumFiles, maxNumBytes);
99              }
100         }
101     }
102 
103     @Override
104     protected void mirrorRemoteToLocal(File workDir, CommandExecutor executor,
105                                        int maxNumFiles, long maxNumBytes) throws Exception {
106         final URIish remoteUri = remoteUri();
107         try (SshClient sshClient = createSshClient();
108              ClientSession session = createSession(sshClient, remoteUri)) {
109             final DefaultGitSshdSessionFactory sessionFactory =
110                     new DefaultGitSshdSessionFactory(sshClient, session);
111             try (GitWithAuth git = openGit(workDir, remoteUri, sessionFactory::configureCommand)) {
112                 mirrorRemoteToLocal(git, executor, maxNumFiles, maxNumBytes);
113             }
114         }
115     }
116 
117     private URIish remoteUri() throws URISyntaxException {
118         // Requires the username to be included in the URI.
119         final String username;
120         if (credential() instanceof PasswordMirrorCredential) {
121             username = ((PasswordMirrorCredential) credential()).username();
122         } else if (credential() instanceof PublicKeyMirrorCredential) {
123             username = ((PublicKeyMirrorCredential) credential()).username();
124         } else {
125             username = null;
126         }
127 
128         assert !remoteRepoUri().getRawAuthority().contains("@") : remoteRepoUri().getRawAuthority();
129         final String jGitUri;
130         if (username != null) {
131             jGitUri = "ssh://" + username + '@' + remoteRepoUri().getRawAuthority() +
132                       remoteRepoUri().getRawPath();
133         } else {
134             jGitUri = "ssh://" + remoteRepoUri().getRawAuthority() + remoteRepoUri().getRawPath();
135         }
136         return new URIish(jGitUri);
137     }
138 
139     private SshClient createSshClient() {
140         final ClientBuilder builder = ClientBuilder.builder();
141         // Do not use local file system.
142         builder.hostConfigEntryResolver(HostConfigEntryResolver.EMPTY);
143         builder.fileSystemFactory(NoneFileSystemFactory.INSTANCE);
144         // Do not verify the server key.
145         builder.serverKeyVerifier((clientSession, remoteAddress, serverKey) -> true);
146         builder.randomFactory(() -> bounceCastleRandom);
147         final SshClient client = builder.build();
148         try {
149             configureCredential(client);
150             client.start();
151             return client;
152         } catch (Throwable t) {
153             client.stop();
154             throw t;
155         }
156     }
157 
158     @VisibleForTesting
159     static ClientSession createSession(SshClient sshClient, URIish uri) {
160         int port = uri.getPort();
161         if (port <= 0) {
162             port = 22; // Use the SSH default port it unspecified.
163         }
164         logger.trace("Connecting to {}:{}", uri.getHost(), port);
165         ClientSession session = null;
166         try {
167             session = sshClient.connect(uri.getUser(), uri.getHost(), port)
168                                .verify(GitModuleProperties.CONNECT_TIMEOUT.getRequired(
169                                        sshClient))
170                                .getSession();
171             session.auth().verify(GitModuleProperties.AUTH_TIMEOUT.getRequired(session));
172             logger.trace("The session established: {}", session);
173             return session;
174         } catch (Throwable t) {
175             if (session != null) {
176                 session.close(true);
177             }
178             throw new RuntimeException("failed to create a session for " + uri + " from " + sshClient, t);
179         }
180     }
181 
182     private void configureCredential(SshClient client) {
183         final MirrorCredential c = credential();
184         if (c instanceof PasswordMirrorCredential) {
185             client.setFilePasswordProvider(passwordProvider(((PasswordMirrorCredential) c).password()));
186         } else if (c instanceof PublicKeyMirrorCredential) {
187             final PublicKeyMirrorCredential cred = (PublicKeyMirrorCredential) credential();
188             final Collection<KeyPair> keyPairs;
189             try {
190                 keyPairs = keyPairResourceParser.loadKeyPairs(null, NamedResource.ofName(cred.username()),
191                                                               passwordProvider(cred.passphrase()),
192                                                               cred.privateKey());
193                 client.setKeyIdentityProvider(KeyIdentityProvider.wrapKeyPairs(keyPairs));
194             } catch (IOException | GeneralSecurityException e) {
195                 throw new MirrorException("Unexpected exception while loading private key. username: " +
196                                           cred.username() + ", publicKey: " +
197                                           publicKeyPreview(cred.publicKey()), e);
198             }
199         }
200     }
201 
202     private static FilePasswordProvider passwordProvider(@Nullable String passphrase) {
203         if (passphrase == null) {
204             return FilePasswordProvider.EMPTY;
205         }
206 
207         return FilePasswordProvider.of(passphrase);
208     }
209 
210     private static final class DefaultGitSshdSessionFactory extends GitSshdSessionFactory {
211         DefaultGitSshdSessionFactory(SshClient client, ClientSession session) {
212             // The constructor is protected, so we should inherit the class.
213             super(client, session);
214         }
215 
216         private void configureCommand(TransportCommand<?, ?> command) {
217             // Need to set the credentials provider, otherwise NPE is raised when creating GitSshdSession.
218             command.setCredentialsProvider(NoopCredentialsProvider.INSTANCE);
219             command.setTransportConfigCallback(transport -> {
220                 final SshTransport sshTransport = (SshTransport) transport;
221                 sshTransport.setSshSessionFactory(this);
222             });
223         }
224     }
225 
226     static final class NoopCredentialsProvider extends CredentialsProvider {
227 
228         static final CredentialsProvider INSTANCE = new NoopCredentialsProvider();
229 
230         @Override
231         public boolean isInteractive() {
232             return true; // Hacky way in order not to use username and password.
233         }
234 
235         @Override
236         public boolean supports(CredentialItem... items) {
237             return false;
238         }
239 
240         @Override
241         public boolean get(URIish uri, CredentialItem... items) throws UnsupportedCredentialItem {
242             return false;
243         }
244     }
245 }