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