View Javadoc
1   /*
2    * Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch>
3    * and other copyright owners as documented in the project's IP log.
4    *
5    * This program and the accompanying materials are made available
6    * under the terms of the Eclipse Distribution License v1.0 which
7    * accompanies this distribution, is reproduced below, and is
8    * available at http://www.eclipse.org/org/documents/edl-v10.php
9    *
10   * All rights reserved.
11   *
12   * Redistribution and use in source and binary forms, with or
13   * without modification, are permitted provided that the following
14   * conditions are met:
15   *
16   * - Redistributions of source code must retain the above copyright
17   *   notice, this list of conditions and the following disclaimer.
18   *
19   * - Redistributions in binary form must reproduce the above
20   *   copyright notice, this list of conditions and the following
21   *   disclaimer in the documentation and/or other materials provided
22   *   with the distribution.
23   *
24   * - Neither the name of the Eclipse Foundation, Inc. nor the
25   *   names of its contributors may be used to endorse or promote
26   *   products derived from this software without specific prior
27   *   written permission.
28   *
29   * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
30   * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
31   * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
32   * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
33   * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
34   * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
35   * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
36   * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
37   * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
38   * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
39   * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
40   * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
41   * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
42   */
43  package org.eclipse.jgit.junit.ssh;
44  
45  import java.io.ByteArrayInputStream;
46  import java.io.IOException;
47  import java.io.InputStream;
48  import java.nio.file.Files;
49  import java.nio.file.Path;
50  import java.security.GeneralSecurityException;
51  import java.security.KeyPair;
52  import java.security.PublicKey;
53  import java.text.MessageFormat;
54  import java.util.ArrayList;
55  import java.util.Collections;
56  import java.util.List;
57  import java.util.Locale;
58  import java.util.concurrent.ExecutorService;
59  import java.util.concurrent.Executors;
60  
61  import org.apache.sshd.common.NamedFactory;
62  import org.apache.sshd.common.SshConstants;
63  import org.apache.sshd.common.config.keys.AuthorizedKeyEntry;
64  import org.apache.sshd.common.config.keys.KeyUtils;
65  import org.apache.sshd.common.config.keys.PublicKeyEntryResolver;
66  import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory;
67  import org.apache.sshd.common.session.Session;
68  import org.apache.sshd.common.util.buffer.Buffer;
69  import org.apache.sshd.common.util.security.SecurityUtils;
70  import org.apache.sshd.server.ServerAuthenticationManager;
71  import org.apache.sshd.server.SshServer;
72  import org.apache.sshd.server.auth.UserAuth;
73  import org.apache.sshd.server.auth.gss.GSSAuthenticator;
74  import org.apache.sshd.server.auth.gss.UserAuthGSS;
75  import org.apache.sshd.server.auth.gss.UserAuthGSSFactory;
76  import org.apache.sshd.server.auth.keyboard.DefaultKeyboardInteractiveAuthenticator;
77  import org.apache.sshd.server.command.AbstractCommandSupport;
78  import org.apache.sshd.server.command.Command;
79  import org.apache.sshd.server.session.ServerSession;
80  import org.apache.sshd.server.shell.UnknownCommand;
81  import org.apache.sshd.server.subsystem.sftp.SftpSubsystemFactory;
82  import org.eclipse.jgit.annotations.NonNull;
83  import org.eclipse.jgit.lib.Repository;
84  import org.eclipse.jgit.transport.ReceivePack;
85  import org.eclipse.jgit.transport.RemoteConfig;
86  import org.eclipse.jgit.transport.UploadPack;
87  
88  /**
89   * A simple ssh/sftp git <em>test</em> server based on Apache MINA sshd.
90   * <p>
91   * Supports only a single repository. Authenticates only the given test user
92   * against his given test public key. Supports fetch and push.
93   * </p>
94   *
95   * @since 5.2
96   */
97  public class SshTestGitServer {
98  
99  	@NonNull
100 	protected final String testUser;
101 
102 	@NonNull
103 	protected final Repository repository;
104 
105 	@NonNull
106 	protected final List<KeyPair> hostKeys = new ArrayList<>();
107 
108 	protected final SshServer server;
109 
110 	@NonNull
111 	protected PublicKey testKey;
112 
113 	private final ExecutorService executorService = Executors
114 			.newFixedThreadPool(2);
115 
116 	/**
117 	 * Creates a ssh git <em>test</em> server. It serves one single repository,
118 	 * and accepts public-key authentication for exactly one test user.
119 	 *
120 	 * @param testUser
121 	 *            user name of the test user
122 	 * @param testKey
123 	 *            <em>private</em> key file of the test user; the server will
124 	 *            only user the public key from it
125 	 * @param repository
126 	 *            to serve
127 	 * @param hostKey
128 	 *            the unencrypted private key to use as host key
129 	 * @throws IOException
130 	 * @throws GeneralSecurityException
131 	 */
132 	public SshTestGitServer(@NonNull String testUser, @NonNull Path testKey,
133 			@NonNull Repository repository, @NonNull byte[] hostKey)
134 			throws IOException, GeneralSecurityException {
135 		this.testUser = testUser;
136 		setTestUserPublicKey(testKey);
137 		this.repository = repository;
138 		server = SshServer.setUpDefaultServer();
139 		// Set host key
140 		try (ByteArrayInputStream in = new ByteArrayInputStream(hostKey)) {
141 			hostKeys.add(SecurityUtils.loadKeyPairIdentity("", in, null));
142 		} catch (IOException | GeneralSecurityException e) {
143 			// Ignore.
144 		}
145 		server.setKeyPairProvider(() -> hostKeys);
146 
147 		configureAuthentication();
148 
149 		List<NamedFactory<Command>> subsystems = configureSubsystems();
150 		if (!subsystems.isEmpty()) {
151 			server.setSubsystemFactories(subsystems);
152 		}
153 
154 		configureShell();
155 
156 		server.setCommandFactory(command -> {
157 			if (command.startsWith(RemoteConfig.DEFAULT_UPLOAD_PACK)) {
158 				return new GitUploadPackCommand(command, executorService);
159 			} else if (command.startsWith(RemoteConfig.DEFAULT_RECEIVE_PACK)) {
160 				return new GitReceivePackCommand(command, executorService);
161 			}
162 			return new UnknownCommand(command);
163 		});
164 	}
165 
166 	private static class FakeUserAuthGSS extends UserAuthGSS {
167 		@Override
168 		protected Boolean doAuth(Buffer buffer, boolean initial)
169 				throws Exception {
170 			// We always reply that we did do this, but then we fail at the
171 			// first token message. That way we can test that the client-side
172 			// sends the correct initial request and then is skipped correctly,
173 			// even if it causes a GSSException if Kerberos isn't configured at
174 			// all.
175 			if (initial) {
176 				ServerSession session = getServerSession();
177 				Buffer b = session.createBuffer(
178 						SshConstants.SSH_MSG_USERAUTH_INFO_REQUEST);
179 				b.putBytes(KRB5_MECH.getDER());
180 				session.writePacket(b);
181 				return null;
182 			}
183 			return Boolean.FALSE;
184 		}
185 	}
186 
187 	private List<NamedFactory<UserAuth>> getAuthFactories() {
188 		List<NamedFactory<UserAuth>> authentications = new ArrayList<>();
189 		authentications.add(new UserAuthGSSFactory() {
190 			@Override
191 			public UserAuth create() {
192 				return new FakeUserAuthGSS();
193 			}
194 		});
195 		authentications.add(
196 				ServerAuthenticationManager.DEFAULT_USER_AUTH_PUBLIC_KEY_FACTORY);
197 		authentications.add(
198 				ServerAuthenticationManager.DEFAULT_USER_AUTH_KB_INTERACTIVE_FACTORY);
199 		authentications.add(
200 				ServerAuthenticationManager.DEFAULT_USER_AUTH_PASSWORD_FACTORY);
201 		return authentications;
202 	}
203 
204 	/**
205 	 * Configures the authentication mechanisms of this test server. Invoked
206 	 * from the constructor. The default sets up public key authentication for
207 	 * the test user, and a gssapi-with-mic authenticator that pretends to
208 	 * support this mechanism, but that then refuses to authenticate anyone.
209 	 */
210 	protected void configureAuthentication() {
211 		server.setUserAuthFactories(getAuthFactories());
212 		// Disable some authentications
213 		server.setPasswordAuthenticator(null);
214 		server.setKeyboardInteractiveAuthenticator(null);
215 		server.setHostBasedAuthenticator(null);
216 		// Pretend we did gssapi-with-mic.
217 		server.setGSSAuthenticator(new GSSAuthenticator() {
218 			@Override
219 			public boolean validateInitialUser(ServerSession session,
220 					String user) {
221 				return false;
222 			}
223 		});
224 		// Accept only the test user/public key
225 		server.setPublickeyAuthenticator((userName, publicKey, session) -> {
226 			return SshTestGitServer.this.testUser.equals(userName) && KeyUtils
227 					.compareKeys(SshTestGitServer.this.testKey, publicKey);
228 		});
229 	}
230 
231 	/**
232 	 * Configures the test server's subsystems (sftp, scp). Invoked from the
233 	 * constructor. The default provides a simple SFTP setup with the root
234 	 * directory as the given repository's .git directory's parent. (I.e., at
235 	 * the directory containing the .git directory.)
236 	 *
237 	 * @return A possibly empty collection of subsystems.
238 	 */
239 	@NonNull
240 	protected List<NamedFactory<Command>> configureSubsystems() {
241 		// SFTP.
242 		server.setFileSystemFactory(new VirtualFileSystemFactory() {
243 
244 			@Override
245 			protected Path computeRootDir(Session session) throws IOException {
246 				return SshTestGitServer.this.repository.getDirectory()
247 						.getParentFile().getAbsoluteFile().toPath();
248 			}
249 		});
250 		return Collections
251 				.singletonList((new SftpSubsystemFactory.Builder()).build());
252 	}
253 
254 	/**
255 	 * Configures shell access for the test server. The default provides no
256 	 * shell at all.
257 	 */
258 	protected void configureShell() {
259 		// No shell
260 		server.setShellFactory(null);
261 	}
262 
263 	/**
264 	 * Adds an additional host key to the server.
265 	 *
266 	 * @param key
267 	 *            path to the private key file; should not be encrypted
268 	 * @param inFront
269 	 *            whether to add the new key before other existing keys
270 	 * @throws IOException
271 	 *             if the file denoted by the {@link Path} {@code key} cannot be
272 	 *             read
273 	 * @throws GeneralSecurityException
274 	 *             if the key contained in the file cannot be read
275 	 */
276 	public void addHostKey(@NonNull Path key, boolean inFront)
277 			throws IOException, GeneralSecurityException {
278 		try (InputStream in = Files.newInputStream(key)) {
279 			KeyPair pair = SecurityUtils.loadKeyPairIdentity(key.toString(), in,
280 					null);
281 			if (inFront) {
282 				hostKeys.add(0, pair);
283 			} else {
284 				hostKeys.add(pair);
285 			}
286 		}
287 	}
288 
289 	/**
290 	 * Enable password authentication. The server will accept the test user's
291 	 * name, converted to all upper-case, as password.
292 	 */
293 	public void enablePasswordAuthentication() {
294 		server.setPasswordAuthenticator((user, pwd, session) -> {
295 			return testUser.equals(user)
296 					&& testUser.toUpperCase(Locale.ROOT).equals(pwd);
297 		});
298 	}
299 
300 	/**
301 	 * Enable keyboard-interactive authentication. The server will accept the
302 	 * test user's name, converted to all upper-case, as password.
303 	 */
304 	public void enableKeyboardInteractiveAuthentication() {
305 		server.setPasswordAuthenticator((user, pwd, session) -> {
306 			return testUser.equals(user)
307 					&& testUser.toUpperCase(Locale.ROOT).equals(pwd);
308 		});
309 		server.setKeyboardInteractiveAuthenticator(
310 				DefaultKeyboardInteractiveAuthenticator.INSTANCE);
311 	}
312 
313 	/**
314 	 * Starts the test server, listening on a random port.
315 	 *
316 	 * @return the port the server listens on; test clients should connect to
317 	 *         that port
318 	 * @throws IOException
319 	 */
320 	public int start() throws IOException {
321 		server.start();
322 		return server.getPort();
323 	}
324 
325 	/**
326 	 * Stops the test server.
327 	 *
328 	 * @throws IOException
329 	 */
330 	public void stop() throws IOException {
331 		executorService.shutdownNow();
332 		server.stop(true);
333 	}
334 
335 	public void setTestUserPublicKey(Path key)
336 			throws IOException, GeneralSecurityException {
337 		this.testKey = AuthorizedKeyEntry.readAuthorizedKeys(key).get(0)
338 				.resolvePublicKey(PublicKeyEntryResolver.IGNORING);
339 	}
340 
341 	private class GitUploadPackCommand extends AbstractCommandSupport {
342 
343 		protected GitUploadPackCommand(String command,
344 				ExecutorService executorService) {
345 			super(command, executorService, false);
346 		}
347 
348 		@Override
349 		public void run() {
350 			UploadPack uploadPack = new UploadPack(repository);
351 			String gitProtocol = getEnvironment().getEnv().get("GIT_PROTOCOL");
352 			if (gitProtocol != null) {
353 				uploadPack
354 						.setExtraParameters(Collections.singleton(gitProtocol));
355 			}
356 			try {
357 				uploadPack.upload(getInputStream(), getOutputStream(),
358 						getErrorStream());
359 				onExit(0);
360 			} catch (IOException e) {
361 				log.warn(
362 						MessageFormat.format("Could not run {0}", getCommand()),
363 						e);
364 				onExit(-1, e.toString());
365 			}
366 		}
367 
368 	}
369 
370 	private class GitReceivePackCommand extends AbstractCommandSupport {
371 
372 		protected GitReceivePackCommand(String command,
373 				ExecutorService executorService) {
374 			super(command, executorService, false);
375 		}
376 
377 		@Override
378 		public void run() {
379 			try {
380 				new ReceivePack(repository).receive(getInputStream(),
381 						getOutputStream(), getErrorStream());
382 				onExit(0);
383 			} catch (IOException e) {
384 				log.warn(
385 						MessageFormat.format("Could not run {0}", getCommand()),
386 						e);
387 				onExit(-1, e.toString());
388 			}
389 		}
390 
391 	}
392 }