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  
59  import org.apache.sshd.common.NamedFactory;
60  import org.apache.sshd.common.NamedResource;
61  import org.apache.sshd.common.SshConstants;
62  import org.apache.sshd.common.config.keys.AuthorizedKeyEntry;
63  import org.apache.sshd.common.config.keys.KeyUtils;
64  import org.apache.sshd.common.config.keys.PublicKeyEntryResolver;
65  import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory;
66  import org.apache.sshd.common.session.Session;
67  import org.apache.sshd.common.util.buffer.Buffer;
68  import org.apache.sshd.common.util.security.SecurityUtils;
69  import org.apache.sshd.common.util.threads.CloseableExecutorService;
70  import org.apache.sshd.common.util.threads.ThreadUtils;
71  import org.apache.sshd.server.ServerAuthenticationManager;
72  import org.apache.sshd.server.SshServer;
73  import org.apache.sshd.server.auth.UserAuth;
74  import org.apache.sshd.server.auth.gss.GSSAuthenticator;
75  import org.apache.sshd.server.auth.gss.UserAuthGSS;
76  import org.apache.sshd.server.auth.gss.UserAuthGSSFactory;
77  import org.apache.sshd.server.auth.keyboard.DefaultKeyboardInteractiveAuthenticator;
78  import org.apache.sshd.server.command.AbstractCommandSupport;
79  import org.apache.sshd.server.command.Command;
80  import org.apache.sshd.server.session.ServerSession;
81  import org.apache.sshd.server.shell.UnknownCommand;
82  import org.apache.sshd.server.subsystem.sftp.SftpSubsystemFactory;
83  import org.eclipse.jgit.annotations.NonNull;
84  import org.eclipse.jgit.lib.Repository;
85  import org.eclipse.jgit.transport.ReceivePack;
86  import org.eclipse.jgit.transport.RemoteConfig;
87  import org.eclipse.jgit.transport.UploadPack;
88  
89  /**
90   * A simple ssh/sftp git <em>test</em> server based on Apache MINA sshd.
91   * <p>
92   * Supports only a single repository. Authenticates only the given test user
93   * against his given test public key. Supports fetch and push.
94   * </p>
95   *
96   * @since 5.2
97   */
98  public class SshTestGitServer {
99  
100 	@NonNull
101 	protected final String testUser;
102 
103 	@NonNull
104 	protected final Repository repository;
105 
106 	@NonNull
107 	protected final List<KeyPair> hostKeys = new ArrayList<>();
108 
109 	protected final SshServer server;
110 
111 	@NonNull
112 	protected PublicKey testKey;
113 
114 	private final CloseableExecutorService executorService = ThreadUtils
115 			.newFixedThreadPool("SshTestGitServerPool", 2);
116 
117 	/**
118 	 * Creates a ssh git <em>test</em> server. It serves one single repository,
119 	 * and accepts public-key authentication for exactly one test user.
120 	 *
121 	 * @param testUser
122 	 *            user name of the test user
123 	 * @param testKey
124 	 *            <em>private</em> key file of the test user; the server will
125 	 *            only user the public key from it
126 	 * @param repository
127 	 *            to serve
128 	 * @param hostKey
129 	 *            the unencrypted private key to use as host key
130 	 * @throws IOException
131 	 * @throws GeneralSecurityException
132 	 */
133 	public SshTestGitServer(@NonNull String testUser, @NonNull Path testKey,
134 			@NonNull Repository repository, @NonNull byte[] hostKey)
135 			throws IOException, GeneralSecurityException {
136 		this.testUser = testUser;
137 		setTestUserPublicKey(testKey);
138 		this.repository = repository;
139 		server = SshServer.setUpDefaultServer();
140 		// Set host key
141 		try (ByteArrayInputStream in = new ByteArrayInputStream(hostKey)) {
142 			SecurityUtils.loadKeyPairIdentities(null, null, in, null)
143 					.forEach((k) -> hostKeys.add(k));
144 		} catch (IOException | GeneralSecurityException e) {
145 			// Ignore.
146 		}
147 		server.setKeyPairProvider((session) -> hostKeys);
148 
149 		configureAuthentication();
150 
151 		List<NamedFactory<Command>> subsystems = configureSubsystems();
152 		if (!subsystems.isEmpty()) {
153 			server.setSubsystemFactories(subsystems);
154 		}
155 
156 		configureShell();
157 
158 		server.setCommandFactory(command -> {
159 			if (command.startsWith(RemoteConfig.DEFAULT_UPLOAD_PACK)) {
160 				return new GitUploadPackCommand(command, executorService);
161 			} else if (command.startsWith(RemoteConfig.DEFAULT_RECEIVE_PACK)) {
162 				return new GitReceivePackCommand(command, executorService);
163 			}
164 			return new UnknownCommand(command);
165 		});
166 	}
167 
168 	private static class FakeUserAuthGSS extends UserAuthGSS {
169 		@Override
170 		protected Boolean doAuth(Buffer buffer, boolean initial)
171 				throws Exception {
172 			// We always reply that we did do this, but then we fail at the
173 			// first token message. That way we can test that the client-side
174 			// sends the correct initial request and then is skipped correctly,
175 			// even if it causes a GSSException if Kerberos isn't configured at
176 			// all.
177 			if (initial) {
178 				ServerSession session = getServerSession();
179 				Buffer b = session.createBuffer(
180 						SshConstants.SSH_MSG_USERAUTH_INFO_REQUEST);
181 				b.putBytes(KRB5_MECH.getDER());
182 				session.writePacket(b);
183 				return null;
184 			}
185 			return Boolean.FALSE;
186 		}
187 	}
188 
189 	private List<NamedFactory<UserAuth>> getAuthFactories() {
190 		List<NamedFactory<UserAuth>> authentications = new ArrayList<>();
191 		authentications.add(new UserAuthGSSFactory() {
192 			@Override
193 			public UserAuth create() {
194 				return new FakeUserAuthGSS();
195 			}
196 		});
197 		authentications.add(
198 				ServerAuthenticationManager.DEFAULT_USER_AUTH_PUBLIC_KEY_FACTORY);
199 		authentications.add(
200 				ServerAuthenticationManager.DEFAULT_USER_AUTH_KB_INTERACTIVE_FACTORY);
201 		authentications.add(
202 				ServerAuthenticationManager.DEFAULT_USER_AUTH_PASSWORD_FACTORY);
203 		return authentications;
204 	}
205 
206 	/**
207 	 * Configures the authentication mechanisms of this test server. Invoked
208 	 * from the constructor. The default sets up public key authentication for
209 	 * the test user, and a gssapi-with-mic authenticator that pretends to
210 	 * support this mechanism, but that then refuses to authenticate anyone.
211 	 */
212 	protected void configureAuthentication() {
213 		server.setUserAuthFactories(getAuthFactories());
214 		// Disable some authentications
215 		server.setPasswordAuthenticator(null);
216 		server.setKeyboardInteractiveAuthenticator(null);
217 		server.setHostBasedAuthenticator(null);
218 		// Pretend we did gssapi-with-mic.
219 		server.setGSSAuthenticator(new GSSAuthenticator() {
220 			@Override
221 			public boolean validateInitialUser(ServerSession session,
222 					String user) {
223 				return false;
224 			}
225 		});
226 		// Accept only the test user/public key
227 		server.setPublickeyAuthenticator((userName, publicKey, session) -> {
228 			return SshTestGitServer.this.testUser.equals(userName) && KeyUtils
229 					.compareKeys(SshTestGitServer.this.testKey, publicKey);
230 		});
231 	}
232 
233 	/**
234 	 * Configures the test server's subsystems (sftp, scp). Invoked from the
235 	 * constructor. The default provides a simple SFTP setup with the root
236 	 * directory as the given repository's .git directory's parent. (I.e., at
237 	 * the directory containing the .git directory.)
238 	 *
239 	 * @return A possibly empty collection of subsystems.
240 	 */
241 	@NonNull
242 	protected List<NamedFactory<Command>> configureSubsystems() {
243 		// SFTP.
244 		server.setFileSystemFactory(new VirtualFileSystemFactory() {
245 
246 			@Override
247 			protected Path computeRootDir(Session session) throws IOException {
248 				return SshTestGitServer.this.repository.getDirectory()
249 						.getParentFile().getAbsoluteFile().toPath();
250 			}
251 		});
252 		return Collections
253 				.singletonList((new SftpSubsystemFactory.Builder()).build());
254 	}
255 
256 	/**
257 	 * Configures shell access for the test server. The default provides no
258 	 * shell at all.
259 	 */
260 	protected void configureShell() {
261 		// No shell
262 		server.setShellFactory(null);
263 	}
264 
265 	/**
266 	 * Adds an additional host key to the server.
267 	 *
268 	 * @param key
269 	 *            path to the private key file; should not be encrypted
270 	 * @param inFront
271 	 *            whether to add the new key before other existing keys
272 	 * @throws IOException
273 	 *             if the file denoted by the {@link Path} {@code key} cannot be
274 	 *             read
275 	 * @throws GeneralSecurityException
276 	 *             if the key contained in the file cannot be read
277 	 */
278 	public void addHostKey(@NonNull Path key, boolean inFront)
279 			throws IOException, GeneralSecurityException {
280 		try (InputStream in = Files.newInputStream(key)) {
281 			KeyPair pair = SecurityUtils
282 					.loadKeyPairIdentities(null,
283 							NamedResource.ofName(key.toString()), in, null)
284 					.iterator().next();
285 			if (inFront) {
286 				hostKeys.add(0, pair);
287 			} else {
288 				hostKeys.add(pair);
289 			}
290 		}
291 	}
292 
293 	/**
294 	 * Enable password authentication. The server will accept the test user's
295 	 * name, converted to all upper-case, as password.
296 	 */
297 	public void enablePasswordAuthentication() {
298 		server.setPasswordAuthenticator((user, pwd, session) -> {
299 			return testUser.equals(user)
300 					&& testUser.toUpperCase(Locale.ROOT).equals(pwd);
301 		});
302 	}
303 
304 	/**
305 	 * Enable keyboard-interactive authentication. The server will accept the
306 	 * test user's name, converted to all upper-case, as password.
307 	 */
308 	public void enableKeyboardInteractiveAuthentication() {
309 		server.setPasswordAuthenticator((user, pwd, session) -> {
310 			return testUser.equals(user)
311 					&& testUser.toUpperCase(Locale.ROOT).equals(pwd);
312 		});
313 		server.setKeyboardInteractiveAuthenticator(
314 				DefaultKeyboardInteractiveAuthenticator.INSTANCE);
315 	}
316 
317 	/**
318 	 * Starts the test server, listening on a random port.
319 	 *
320 	 * @return the port the server listens on; test clients should connect to
321 	 *         that port
322 	 * @throws IOException
323 	 */
324 	public int start() throws IOException {
325 		server.start();
326 		return server.getPort();
327 	}
328 
329 	/**
330 	 * Stops the test server.
331 	 *
332 	 * @throws IOException
333 	 */
334 	public void stop() throws IOException {
335 		executorService.shutdownNow();
336 		server.stop(true);
337 	}
338 
339 	public void setTestUserPublicKey(Path key)
340 			throws IOException, GeneralSecurityException {
341 		this.testKey = AuthorizedKeyEntry.readAuthorizedKeys(key).get(0)
342 				.resolvePublicKey(null, PublicKeyEntryResolver.IGNORING);
343 	}
344 
345 	private class GitUploadPackCommand extends AbstractCommandSupport {
346 
347 		protected GitUploadPackCommand(String command,
348 				CloseableExecutorService executorService) {
349 			super(command, ThreadUtils.noClose(executorService));
350 		}
351 
352 		@Override
353 		public void run() {
354 			UploadPack uploadPack = new UploadPack(repository);
355 			String gitProtocol = getEnvironment().getEnv().get("GIT_PROTOCOL");
356 			if (gitProtocol != null) {
357 				uploadPack
358 						.setExtraParameters(Collections.singleton(gitProtocol));
359 			}
360 			try {
361 				uploadPack.upload(getInputStream(), getOutputStream(),
362 						getErrorStream());
363 				onExit(0);
364 			} catch (IOException e) {
365 				log.warn(
366 						MessageFormat.format("Could not run {0}", getCommand()),
367 						e);
368 				onExit(-1, e.toString());
369 			}
370 		}
371 
372 	}
373 
374 	private class GitReceivePackCommand extends AbstractCommandSupport {
375 
376 		protected GitReceivePackCommand(String command,
377 				CloseableExecutorService executorService) {
378 			super(command, ThreadUtils.noClose(executorService));
379 		}
380 
381 		@Override
382 		public void run() {
383 			try {
384 				new ReceivePack(repository).receive(getInputStream(),
385 						getOutputStream(), getErrorStream());
386 				onExit(0);
387 			} catch (IOException e) {
388 				log.warn(
389 						MessageFormat.format("Could not run {0}", getCommand()),
390 						e);
391 				onExit(-1, e.toString());
392 			}
393 		}
394 
395 	}
396 }