1 /* 2 * Copyright (C) 2018, 2020 Thomas Wolf <thomas.wolf@paranor.ch> and others 3 * 4 * This program and the accompanying materials are made available under the 5 * terms of the Eclipse Distribution License v. 1.0 which is available at 6 * https://www.eclipse.org/org/documents/edl-v10.php. 7 * 8 * SPDX-License-Identifier: BSD-3-Clause 9 */ 10 package org.eclipse.jgit.junit.ssh; 11 12 import static org.apache.sshd.core.CoreModuleProperties.SERVER_EXTRA_IDENTIFICATION_LINES; 13 import static org.apache.sshd.core.CoreModuleProperties.SERVER_EXTRA_IDENT_LINES_SEPARATOR; 14 15 import java.io.ByteArrayInputStream; 16 import java.io.IOException; 17 import java.io.InputStream; 18 import java.io.OutputStream; 19 import java.nio.charset.StandardCharsets; 20 import java.nio.file.Files; 21 import java.nio.file.Path; 22 import java.security.GeneralSecurityException; 23 import java.security.KeyPair; 24 import java.security.PublicKey; 25 import java.text.MessageFormat; 26 import java.util.ArrayList; 27 import java.util.Arrays; 28 import java.util.Collections; 29 import java.util.List; 30 import java.util.Locale; 31 import java.util.concurrent.TimeUnit; 32 33 import org.apache.sshd.common.NamedFactory; 34 import org.apache.sshd.common.NamedResource; 35 import org.apache.sshd.common.PropertyResolver; 36 import org.apache.sshd.common.SshConstants; 37 import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; 38 import org.apache.sshd.common.config.keys.KeyUtils; 39 import org.apache.sshd.common.config.keys.PublicKeyEntryResolver; 40 import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory; 41 import org.apache.sshd.common.signature.BuiltinSignatures; 42 import org.apache.sshd.common.signature.Signature; 43 import org.apache.sshd.common.util.buffer.Buffer; 44 import org.apache.sshd.common.util.security.SecurityUtils; 45 import org.apache.sshd.common.util.threads.CloseableExecutorService; 46 import org.apache.sshd.common.util.threads.ThreadUtils; 47 import org.apache.sshd.server.ServerAuthenticationManager; 48 import org.apache.sshd.server.ServerBuilder; 49 import org.apache.sshd.server.SshServer; 50 import org.apache.sshd.server.auth.UserAuth; 51 import org.apache.sshd.server.auth.UserAuthFactory; 52 import org.apache.sshd.server.auth.gss.GSSAuthenticator; 53 import org.apache.sshd.server.auth.gss.UserAuthGSS; 54 import org.apache.sshd.server.auth.gss.UserAuthGSSFactory; 55 import org.apache.sshd.server.auth.keyboard.DefaultKeyboardInteractiveAuthenticator; 56 import org.apache.sshd.server.command.AbstractCommandSupport; 57 import org.apache.sshd.server.session.ServerSession; 58 import org.apache.sshd.server.shell.UnknownCommand; 59 import org.apache.sshd.server.subsystem.SubsystemFactory; 60 import org.apache.sshd.sftp.server.SftpSubsystemFactory; 61 import org.eclipse.jgit.annotations.NonNull; 62 import org.eclipse.jgit.annotations.Nullable; 63 import org.eclipse.jgit.lib.Repository; 64 import org.eclipse.jgit.transport.ReceivePack; 65 import org.eclipse.jgit.transport.RemoteConfig; 66 import org.eclipse.jgit.transport.UploadPack; 67 68 /** 69 * A simple ssh/sftp git <em>test</em> server based on Apache MINA sshd. 70 * <p> 71 * Supports only a single repository. Authenticates only the given test user 72 * against his given test public key. Supports fetch and push. 73 * </p> 74 * 75 * @since 5.2 76 */ 77 public class SshTestGitServer { 78 79 /** 80 * Simple echo test command. Replies with the command string as passed. If 81 * of the form "echo [int] anything", takes the integer value as a delay in 82 * seconds before replying, which may be useful to test various 83 * timeout-related things. 84 * 85 * @since 5.9 86 */ 87 public static final String ECHO_COMMAND = "echo"; 88 89 @NonNull 90 protected final String testUser; 91 92 @NonNull 93 protected final Repository repository; 94 95 @NonNull 96 protected final List<KeyPair> hostKeys = new ArrayList<>(); 97 98 protected final SshServer server; 99 100 @NonNull 101 protected PublicKey testKey; 102 103 private final CloseableExecutorService executorService = ThreadUtils 104 .newFixedThreadPool("SshTestGitServerPool", 2); 105 106 /** 107 * Creates a ssh git <em>test</em> server. It serves one single repository, 108 * and accepts public-key authentication for exactly one test user. 109 * 110 * @param testUser 111 * user name of the test user 112 * @param testKey 113 * public key file of the test user 114 * @param repository 115 * to serve 116 * @param hostKey 117 * the unencrypted private key to use as host key 118 * @throws IOException 119 * @throws GeneralSecurityException 120 */ SshTestGitServer(@onNull String testUser, @NonNull Path testKey, @NonNull Repository repository, @NonNull byte[] hostKey)121 public SshTestGitServer(@NonNull String testUser, @NonNull Path testKey, 122 @NonNull Repository repository, @NonNull byte[] hostKey) 123 throws IOException, GeneralSecurityException { 124 this(testUser, readPublicKey(testKey), repository, 125 readKeyPair(hostKey)); 126 } 127 128 /** 129 * Creates a ssh git <em>test</em> server. It serves one single repository, 130 * and accepts public-key authentication for exactly one test user. 131 * 132 * @param testUser 133 * user name of the test user 134 * @param testKey 135 * public key file of the test user 136 * @param repository 137 * to serve 138 * @param hostKey 139 * the unencrypted private key to use as host key 140 * @throws IOException 141 * @throws GeneralSecurityException 142 * @since 5.9 143 */ SshTestGitServer(@onNull String testUser, @NonNull Path testKey, @NonNull Repository repository, @NonNull KeyPair hostKey)144 public SshTestGitServer(@NonNull String testUser, @NonNull Path testKey, 145 @NonNull Repository repository, @NonNull KeyPair hostKey) 146 throws IOException, GeneralSecurityException { 147 this(testUser, readPublicKey(testKey), repository, hostKey); 148 } 149 150 /** 151 * Creates a ssh git <em>test</em> server. It serves one single repository, 152 * and accepts public-key authentication for exactly one test user. 153 * 154 * @param testUser 155 * user name of the test user 156 * @param testKey 157 * the {@link PublicKey} of the test user 158 * @param repository 159 * to serve 160 * @param hostKey 161 * the {@link KeyPair} to use as host key 162 * @since 5.9 163 */ SshTestGitServer(@onNull String testUser, @NonNull PublicKey testKey, @NonNull Repository repository, @NonNull KeyPair hostKey)164 public SshTestGitServer(@NonNull String testUser, 165 @NonNull PublicKey testKey, @NonNull Repository repository, 166 @NonNull KeyPair hostKey) { 167 this.testUser = testUser; 168 setTestUserPublicKey(testKey); 169 this.repository = repository; 170 ServerBuilder builder = ServerBuilder.builder() 171 .signatureFactories(getSignatureFactories()); 172 server = builder.build(); 173 hostKeys.add(hostKey); 174 server.setKeyPairProvider((session) -> hostKeys); 175 176 configureAuthentication(); 177 178 List<SubsystemFactory> subsystems = configureSubsystems(); 179 if (!subsystems.isEmpty()) { 180 server.setSubsystemFactories(subsystems); 181 } 182 183 configureShell(); 184 185 server.setCommandFactory((channel, command) -> { 186 if (command.startsWith(RemoteConfig.DEFAULT_UPLOAD_PACK)) { 187 return new GitUploadPackCommand(command, executorService); 188 } else if (command.startsWith(RemoteConfig.DEFAULT_RECEIVE_PACK)) { 189 return new GitReceivePackCommand(command, executorService); 190 } else if (command.startsWith(ECHO_COMMAND)) { 191 return new EchoCommand(command, executorService); 192 } 193 return new UnknownCommand(command); 194 }); 195 } 196 197 /** 198 * Apache MINA sshd 2.6.0 has removed DSA, DSA_CERT and RSA_CERT. We have to 199 * set it up explicitly to still allow users to connect with DSA keys. 200 * 201 * @return a list of supported signature factories 202 */ 203 @SuppressWarnings("deprecation") getSignatureFactories()204 private static List<NamedFactory<Signature>> getSignatureFactories() { 205 // @formatter:off 206 return Arrays.asList( 207 BuiltinSignatures.nistp256_cert, 208 BuiltinSignatures.nistp384_cert, 209 BuiltinSignatures.nistp521_cert, 210 BuiltinSignatures.ed25519_cert, 211 BuiltinSignatures.rsaSHA512_cert, 212 BuiltinSignatures.rsaSHA256_cert, 213 BuiltinSignatures.rsa_cert, 214 BuiltinSignatures.nistp256, 215 BuiltinSignatures.nistp384, 216 BuiltinSignatures.nistp521, 217 BuiltinSignatures.ed25519, 218 BuiltinSignatures.sk_ecdsa_sha2_nistp256, 219 BuiltinSignatures.sk_ssh_ed25519, 220 BuiltinSignatures.rsaSHA512, 221 BuiltinSignatures.rsaSHA256, 222 BuiltinSignatures.rsa, 223 BuiltinSignatures.dsa_cert, 224 BuiltinSignatures.dsa); 225 // @formatter:on 226 } 227 readPublicKey(Path key)228 private static PublicKey readPublicKey(Path key) 229 throws IOException, GeneralSecurityException { 230 return AuthorizedKeyEntry.readAuthorizedKeys(key).get(0) 231 .resolvePublicKey(null, PublicKeyEntryResolver.IGNORING); 232 } 233 readKeyPair(byte[] keyMaterial)234 private static KeyPair readKeyPair(byte[] keyMaterial) 235 throws IOException, GeneralSecurityException { 236 try (ByteArrayInputStream in = new ByteArrayInputStream(keyMaterial)) { 237 return SecurityUtils.loadKeyPairIdentities(null, null, in, null) 238 .iterator().next(); 239 } 240 } 241 242 private static class FakeUserAuthGSS extends UserAuthGSS { 243 @Override doAuth(Buffer buffer, boolean initial)244 protected @Nullable Boolean doAuth(Buffer buffer, boolean initial) 245 throws Exception { 246 // We always reply that we did do this, but then we fail at the 247 // first token message. That way we can test that the client-side 248 // sends the correct initial request and then is skipped correctly, 249 // even if it causes a GSSException if Kerberos isn't configured at 250 // all. 251 if (initial) { 252 ServerSession session = getServerSession(); 253 Buffer b = session.createBuffer( 254 SshConstants.SSH_MSG_USERAUTH_INFO_REQUEST); 255 b.putBytes(KRB5_MECH.getDER()); 256 session.writePacket(b); 257 return null; 258 } 259 return Boolean.FALSE; 260 } 261 } 262 getAuthFactories()263 private List<UserAuthFactory> getAuthFactories() { 264 List<UserAuthFactory> authentications = new ArrayList<>(); 265 authentications.add(new UserAuthGSSFactory() { 266 @Override 267 public UserAuth createUserAuth(ServerSession session) 268 throws IOException { 269 return new FakeUserAuthGSS(); 270 } 271 }); 272 authentications.add( 273 ServerAuthenticationManager.DEFAULT_USER_AUTH_PUBLIC_KEY_FACTORY); 274 authentications.add( 275 ServerAuthenticationManager.DEFAULT_USER_AUTH_KB_INTERACTIVE_FACTORY); 276 authentications.add( 277 ServerAuthenticationManager.DEFAULT_USER_AUTH_PASSWORD_FACTORY); 278 return authentications; 279 } 280 281 /** 282 * Configures the authentication mechanisms of this test server. Invoked 283 * from the constructor. The default sets up public key authentication for 284 * the test user, and a gssapi-with-mic authenticator that pretends to 285 * support this mechanism, but that then refuses to authenticate anyone. 286 */ configureAuthentication()287 protected void configureAuthentication() { 288 server.setUserAuthFactories(getAuthFactories()); 289 // Disable some authentications 290 server.setPasswordAuthenticator(null); 291 server.setKeyboardInteractiveAuthenticator(null); 292 server.setHostBasedAuthenticator(null); 293 // Pretend we did gssapi-with-mic. 294 server.setGSSAuthenticator(new GSSAuthenticator() { 295 @Override 296 public boolean validateInitialUser(ServerSession session, 297 String user) { 298 return false; 299 } 300 }); 301 // Accept only the test user/public key 302 server.setPublickeyAuthenticator((userName, publicKey, session) -> { 303 return SshTestGitServer.this.testUser.equals(userName) && KeyUtils 304 .compareKeys(SshTestGitServer.this.testKey, publicKey); 305 }); 306 } 307 308 /** 309 * Configures the test server's subsystems (sftp, scp). Invoked from the 310 * constructor. The default provides a simple SFTP setup with the root 311 * directory as the given repository's .git directory's parent. (I.e., at 312 * the directory containing the .git directory.) 313 * 314 * @return A possibly empty collection of subsystems. 315 */ 316 @NonNull configureSubsystems()317 protected List<SubsystemFactory> configureSubsystems() { 318 // SFTP. 319 server.setFileSystemFactory(new VirtualFileSystemFactory(repository 320 .getDirectory().getParentFile().getAbsoluteFile().toPath())); 321 return Collections 322 .singletonList((new SftpSubsystemFactory.Builder()).build()); 323 } 324 325 /** 326 * Configures shell access for the test server. The default provides no 327 * shell at all. 328 */ configureShell()329 protected void configureShell() { 330 // No shell 331 server.setShellFactory(null); 332 } 333 334 /** 335 * Adds an additional host key to the server. 336 * 337 * @param key 338 * path to the private key file; should not be encrypted 339 * @param inFront 340 * whether to add the new key before other existing keys 341 * @throws IOException 342 * if the file denoted by the {@link Path} {@code key} cannot be 343 * read 344 * @throws GeneralSecurityException 345 * if the key contained in the file cannot be read 346 */ addHostKey(@onNull Path key, boolean inFront)347 public void addHostKey(@NonNull Path key, boolean inFront) 348 throws IOException, GeneralSecurityException { 349 try (InputStream in = Files.newInputStream(key)) { 350 KeyPair pair = SecurityUtils 351 .loadKeyPairIdentities(null, 352 NamedResource.ofName(key.toString()), in, null) 353 .iterator().next(); 354 addHostKey(pair, inFront); 355 } 356 } 357 358 /** 359 * Adds an additional host key to the server. 360 * 361 * @param key 362 * {@link KeyPair} to add 363 * @param inFront 364 * whether to add the new key before other existing keys 365 * @since 5.8 366 */ addHostKey(@onNull KeyPair key, boolean inFront)367 public void addHostKey(@NonNull KeyPair key, boolean inFront) { 368 if (inFront) { 369 hostKeys.add(0, key); 370 } else { 371 hostKeys.add(key); 372 } 373 } 374 375 /** 376 * Enable password authentication. The server will accept the test user's 377 * name, converted to all upper-case, as password. 378 */ enablePasswordAuthentication()379 public void enablePasswordAuthentication() { 380 server.setPasswordAuthenticator((user, pwd, session) -> { 381 return testUser.equals(user) 382 && testUser.toUpperCase(Locale.ROOT).equals(pwd); 383 }); 384 } 385 386 /** 387 * Enable keyboard-interactive authentication. The server will accept the 388 * test user's name, converted to all upper-case, as password. 389 */ enableKeyboardInteractiveAuthentication()390 public void enableKeyboardInteractiveAuthentication() { 391 server.setPasswordAuthenticator((user, pwd, session) -> { 392 return testUser.equals(user) 393 && testUser.toUpperCase(Locale.ROOT).equals(pwd); 394 }); 395 server.setKeyboardInteractiveAuthenticator( 396 DefaultKeyboardInteractiveAuthenticator.INSTANCE); 397 } 398 399 /** 400 * Retrieves the server's {@link PropertyResolver}, giving access to server 401 * properties. 402 * 403 * @return the {@link PropertyResolver} 404 * @since 5.9 405 */ getPropertyResolver()406 public PropertyResolver getPropertyResolver() { 407 return server; 408 } 409 410 /** 411 * Starts the test server, listening on a random port. 412 * 413 * @return the port the server listens on; test clients should connect to 414 * that port 415 * @throws IOException 416 */ start()417 public int start() throws IOException { 418 server.start(); 419 return server.getPort(); 420 } 421 422 /** 423 * Stops the test server. 424 * 425 * @throws IOException 426 */ stop()427 public void stop() throws IOException { 428 executorService.shutdownNow(); 429 server.stop(true); 430 } 431 432 /** 433 * Sets the test user's public key on the server. 434 * 435 * @param key 436 * to set 437 * @throws IOException 438 * if the file cannot be read 439 * @throws GeneralSecurityException 440 * if the public key cannot be extracted from the file 441 */ setTestUserPublicKey(Path key)442 public void setTestUserPublicKey(Path key) 443 throws IOException, GeneralSecurityException { 444 this.testKey = readPublicKey(key); 445 } 446 447 /** 448 * Sets the test user's public key on the server. 449 * 450 * @param key 451 * to set 452 * 453 * @since 5.8 454 */ setTestUserPublicKey(@onNull PublicKey key)455 public void setTestUserPublicKey(@NonNull PublicKey key) { 456 this.testKey = key; 457 } 458 459 /** 460 * Sets the lines the server sends before its server identification in the 461 * initial protocol version exchange. 462 * 463 * @param lines 464 * to send 465 * @since 5.5 466 */ setPreamble(String... lines)467 public void setPreamble(String... lines) { 468 if (lines != null && lines.length > 0) { 469 SERVER_EXTRA_IDENTIFICATION_LINES.set(server, String.join( 470 String.valueOf(SERVER_EXTRA_IDENT_LINES_SEPARATOR), lines)); 471 } 472 } 473 474 private class GitUploadPackCommand extends AbstractCommandSupport { 475 GitUploadPackCommand(String command, CloseableExecutorService executorService)476 protected GitUploadPackCommand(String command, 477 CloseableExecutorService executorService) { 478 super(command, ThreadUtils.noClose(executorService)); 479 } 480 481 @Override run()482 public void run() { 483 UploadPack uploadPack = new UploadPack(repository); 484 String gitProtocol = getEnvironment().getEnv().get("GIT_PROTOCOL"); 485 if (gitProtocol != null) { 486 uploadPack 487 .setExtraParameters(Collections.singleton(gitProtocol)); 488 } 489 try { 490 uploadPack.upload(getInputStream(), getOutputStream(), 491 getErrorStream()); 492 onExit(0); 493 } catch (IOException e) { 494 log.warn( 495 MessageFormat.format("Could not run {0}", getCommand()), 496 e); 497 onExit(-1, e.toString()); 498 } 499 } 500 501 } 502 503 private class GitReceivePackCommand extends AbstractCommandSupport { 504 GitReceivePackCommand(String command, CloseableExecutorService executorService)505 protected GitReceivePackCommand(String command, 506 CloseableExecutorService executorService) { 507 super(command, ThreadUtils.noClose(executorService)); 508 } 509 510 @Override run()511 public void run() { 512 try { 513 new ReceivePack(repository).receive(getInputStream(), 514 getOutputStream(), getErrorStream()); 515 onExit(0); 516 } catch (IOException e) { 517 log.warn( 518 MessageFormat.format("Could not run {0}", getCommand()), 519 e); 520 onExit(-1, e.toString()); 521 } 522 } 523 524 } 525 526 /** 527 * Simple echo command that echoes back the command string. If the first 528 * argument is a positive integer, it's taken as a delay (in seconds) before 529 * replying. Assumes UTF-8 character encoding. 530 */ 531 private static class EchoCommand extends AbstractCommandSupport { 532 EchoCommand(String command, CloseableExecutorService executorService)533 protected EchoCommand(String command, 534 CloseableExecutorService executorService) { 535 super(command, ThreadUtils.noClose(executorService)); 536 } 537 538 @Override run()539 public void run() { 540 String[] parts = getCommand().split(" "); 541 int timeout = 0; 542 if (parts.length >= 2) { 543 try { 544 timeout = Integer.parseInt(parts[1]); 545 } catch (NumberFormatException e) { 546 // No timeout. 547 } 548 if (timeout > 0) { 549 try { 550 Thread.sleep(TimeUnit.SECONDS.toMillis(timeout)); 551 } catch (InterruptedException e) { 552 // Ignore. 553 } 554 } 555 } 556 try { 557 doEcho(getCommand(), getOutputStream()); 558 onExit(0); 559 } catch (IOException e) { 560 log.warn( 561 MessageFormat.format("Could not run {0}", getCommand()), 562 e); 563 onExit(-1, e.toString()); 564 } 565 } 566 doEcho(String text, OutputStream stream)567 private void doEcho(String text, OutputStream stream) 568 throws IOException { 569 stream.write(text.getBytes(StandardCharsets.UTF_8)); 570 stream.flush(); 571 } 572 } 573 } 574