diff --git a/extensions/ssh/deployment/src/main/java/org/apache/camel/quarkus/component/ssh/deployment/SshProcessor.java b/extensions/ssh/deployment/src/main/java/org/apache/camel/quarkus/component/ssh/deployment/SshProcessor.java index c1ed5f65ddf0..7935194f56d2 100644 --- a/extensions/ssh/deployment/src/main/java/org/apache/camel/quarkus/component/ssh/deployment/SshProcessor.java +++ b/extensions/ssh/deployment/src/main/java/org/apache/camel/quarkus/component/ssh/deployment/SshProcessor.java @@ -26,14 +26,18 @@ import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.IndexDependencyBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageProxyDefinitionBuildItem; import io.quarkus.deployment.builditem.nativeimage.NativeImageResourceBuildItem; import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; +import net.i2p.crypto.eddsa.EdDSAEngine; import org.apache.sshd.common.channel.ChannelListener; import org.apache.sshd.common.forward.PortForwardingEventListener; import org.apache.sshd.common.io.nio2.Nio2ServiceFactoryFactory; import org.apache.sshd.common.session.SessionListener; +import org.jboss.jandex.IndexView; class SshProcessor { @@ -51,9 +55,10 @@ void registerForReflection(BuildProducer reflectiveCla KeyAgreement.class, KeyFactory.class, Signature.class, - Mac.class).methods().build()); - reflectiveClasses.produce( - ReflectiveClassBuildItem.builder(Nio2ServiceFactoryFactory.class).build()); + Mac.class, + Nio2ServiceFactoryFactory.class, + EdDSAEngine.class, + net.i2p.crypto.eddsa.KeyFactory.class).methods().build()); } @BuildStep @@ -71,4 +76,22 @@ void sessionProxy(BuildProducer proxiesProd } } + @BuildStep + ReflectiveClassBuildItem registerForReflection(CombinedIndexBuildItem combinedIndex) { + IndexView index = combinedIndex.getIndex(); + + String[] dtos = index.getKnownClasses().stream() + .map(ci -> ci.name().toString()) + .filter(n -> n.startsWith("org.bouncycastle.crypto.signers.Ed25519")) + .sorted() + .toArray(String[]::new); + + return ReflectiveClassBuildItem.builder(dtos).methods().fields().build(); + } + + @BuildStep + IndexDependencyBuildItem registerDependencyForIndex2() { + return new IndexDependencyBuildItem("org.bouncycastle", "bcprov-jdk18on"); + } + } diff --git a/extensions/ssh/runtime/src/main/java/org/apache/camel/quarkus/component/ssh/runtime/SubstituteEdDSAEngine.java b/extensions/ssh/runtime/src/main/java/org/apache/camel/quarkus/component/ssh/runtime/SubstituteEdDSAEngine.java new file mode 100644 index 000000000000..3054c049ccbb --- /dev/null +++ b/extensions/ssh/runtime/src/main/java/org/apache/camel/quarkus/component/ssh/runtime/SubstituteEdDSAEngine.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.quarkus.component.ssh.runtime; + +import java.security.*; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; + +import com.oracle.svm.core.annotate.Alias; +import com.oracle.svm.core.annotate.Substitute; +import com.oracle.svm.core.annotate.TargetClass; +import net.i2p.crypto.eddsa.EdDSAEngine; +import net.i2p.crypto.eddsa.EdDSAKey; +import net.i2p.crypto.eddsa.EdDSAPublicKey; + +/** + * We're substituting those offending methods that would require the presence of + * net.i2p.crypto:eddsa library which is not supported by Camel SSH component + */ +@TargetClass(EdDSAEngine.class) +final class SubstituteEdDSAEngine { + + @Alias + private MessageDigest digest; + + @Alias + private EdDSAKey key; + + @Alias + private void reset() { + } + + @Substitute + protected void engineInitVerify(PublicKey publicKey) throws InvalidKeyException { + reset(); + if (publicKey instanceof EdDSAPublicKey) { + key = (EdDSAPublicKey) publicKey; + + if (digest == null) { + // Instantiate the digest from the key parameters + try { + digest = MessageDigest.getInstance(key.getParams().getHashAlgorithm()); + } catch (NoSuchAlgorithmException e) { + throw new InvalidKeyException( + "cannot get required digest " + key.getParams().getHashAlgorithm() + " for private key."); + } + } else if (!key.getParams().getHashAlgorithm().equals(digest.getAlgorithm())) + throw new InvalidKeyException("Key hash algorithm does not match chosen digest"); + } //following line differs from the original method + else if (publicKey.getFormat().equals("X.509")) { + // X509Certificate will sometimes contain an X509Key rather than the EdDSAPublicKey itself; the contained + // key is valid but needs to be instanced as an EdDSAPublicKey before it can be used. + EdDSAPublicKey parsedPublicKey; + try { + parsedPublicKey = new EdDSAPublicKey(new X509EncodedKeySpec(publicKey.getEncoded())); + } catch (InvalidKeySpecException ex) { + throw new InvalidKeyException("cannot handle X.509 EdDSA public key: " + publicKey.getAlgorithm()); + } + engineInitVerify(parsedPublicKey); + } else { + throw new InvalidKeyException("cannot identify EdDSA public key: " + publicKey.getClass()); + } + } +} diff --git a/extensions/ssh/runtime/src/main/java/org/apache/camel/quarkus/component/ssh/runtime/SubstituteSecurityUtils.java b/extensions/ssh/runtime/src/main/java/org/apache/camel/quarkus/component/ssh/runtime/SubstituteSecurityUtils.java deleted file mode 100644 index 2bca5bc96a0e..000000000000 --- a/extensions/ssh/runtime/src/main/java/org/apache/camel/quarkus/component/ssh/runtime/SubstituteSecurityUtils.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.camel.quarkus.component.ssh.runtime; - -import java.security.GeneralSecurityException; -import java.security.PrivateKey; -import java.security.PublicKey; - -import com.oracle.svm.core.annotate.Substitute; -import com.oracle.svm.core.annotate.TargetClass; -import org.apache.sshd.common.util.buffer.Buffer; -import org.apache.sshd.common.util.security.SecurityUtils; - -/** - * We're substituting those offending methods that would require the presence of - * net.i2p.crypto:eddsa library which is not supported by Camel SSH component - */ -@TargetClass(SecurityUtils.class) -final class SubstituteSecurityUtils { - - @Substitute - public static boolean compareEDDSAPPublicKeys(PublicKey k1, PublicKey k2) { - throw new UnsupportedOperationException("EdDSA Signer not available"); - } - - @Substitute - public static boolean compareEDDSAPrivateKeys(PrivateKey k1, PrivateKey k2) { - throw new UnsupportedOperationException("EdDSA Signer not available"); - } - - @Substitute - public static PublicKey generateEDDSAPublicKey(String keyType, byte[] seed) throws GeneralSecurityException { - throw new UnsupportedOperationException("EdDSA Signer not available"); - } - - @Substitute - public static org.apache.sshd.common.signature.Signature getEDDSASigner() { - throw new UnsupportedOperationException("EdDSA Signer not available"); - } - - @Substitute - public static B putRawEDDSAPublicKey(B buffer, PublicKey key) { - throw new UnsupportedOperationException("EdDSA Signer not available"); - } - - @Substitute - public static PublicKey recoverEDDSAPublicKey(PrivateKey key) throws GeneralSecurityException { - throw new UnsupportedOperationException("EdDSA Signer not available"); - } - -} diff --git a/integration-tests/ssh/README.adoc b/integration-tests/ssh/README.adoc new file mode 100644 index 000000000000..2e19bd088d71 --- /dev/null +++ b/integration-tests/ssh/README.adoc @@ -0,0 +1,7 @@ +== FIPS + +Ssh extension is not designed to work in FIPS environment. +However all the tests except EdDSA one works in FIPS environment. +The sshd library is using BouncyCastle for the security stuff. + +* Use `fips` profile to skip generation of EdDSA key pair. (Otherwise the script fails because `ED25519 keys are not allowed in FIPS mode`. diff --git a/integration-tests/ssh/generate-certs.sh b/integration-tests/ssh/generate-certs.sh new file mode 100644 index 000000000000..92e227b51a0e --- /dev/null +++ b/integration-tests/ssh/generate-certs.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +set -e +set -x + +keyType="ed25519" + +destinationDir="target/classes/edDSA" + +# see https://stackoverflow.com/a/54924640 +export MSYS_NO_PATHCONV=1 + +if [[ -n "${JAVA_HOME}" ]] ; then + keytool="$JAVA_HOME/bin/keytool" +elif ! [[ -x "$(command -v keytool)" ]] ; then + echo 'Error: Either add keytool to PATH or set JAVA_HOME' >&2 + exit 1 +else + keytool="keytool" +fi + +if ! [[ -x "$(command -v openssl)" ]] ; then + echo 'Error: openssl is not installed.' >&2 + exit 1 +fi + +mkdir -p "$destinationDir" + +# Ed25519 private key +#openssl genpkey -algorithm ed25519 -out "$destinationDir/key_ed25519.pem" +ssh-keygen -t ed25519 -o -a 100 -N "" -f "$destinationDir/key_ed25519.pem" -C "test@localhost" + + +# Ed25519 public key +#openssl pkey -in "$destinationDir/key_ed25519.pem" -pubout -out "$destinationDir/key_ed25519.pem.pub" +ssh-keygen -y -f "$destinationDir/key_ed25519.pem" > "$destinationDir/key_ed25519.pem.pub" + +#generate known-hosts +echo -n "127.0.0.1 $(sed 's/\(.*\) \([^ ]*\)$/\1/' "$destinationDir/key_ed25519.pem.pub")" >> "$destinationDir/known_hosts_eddsa" +#echo -n "127.0.0.1 ssh-ed25519 $(sed -e '1d' -e '$d' "$destinationDir/key_ed25519.pem.pub")" >> "$destinationDir/known_hosts_eddsa" + + diff --git a/integration-tests/ssh/pom.xml b/integration-tests/ssh/pom.xml index 42b639eb61e5..a25a8aed5fc8 100644 --- a/integration-tests/ssh/pom.xml +++ b/integration-tests/ssh/pom.xml @@ -35,10 +35,18 @@ org.apache.camel.quarkus camel-quarkus-ssh + + org.apache.camel.quarkus + camel-quarkus-direct + io.quarkus quarkus-resteasy + + io.quarkus + quarkus-resteasy-jackson + @@ -51,6 +59,11 @@ rest-assured test + + org.assertj + assertj-core + test + org.testcontainers testcontainers @@ -67,9 +80,13 @@ quarkus-junit4-mock test + + org.apache.camel.quarkus + camel-quarkus-integration-tests-support-certificate-generator + test + - native @@ -98,6 +115,48 @@ + + non-fips + + + !fips + + + + + + org.codehaus.mojo + exec-maven-plugin + + + generate-certs.sh + generate-sources + + exec + + + bash + + ${basedir}/generate-certs.sh + + + + + + + + + + + + fips + + + fips + + + + virtualDependencies @@ -120,6 +179,19 @@ + + org.apache.camel.quarkus + camel-quarkus-direct-deployment + ${project.version} + pom + test + + + * + * + + + diff --git a/integration-tests/ssh/src/main/java/org/apache/camel/quarkus/component/ssh/it/SshResource.java b/integration-tests/ssh/src/main/java/org/apache/camel/quarkus/component/ssh/it/SshResource.java index 858666f740ff..6abd086b1b04 100644 --- a/integration-tests/ssh/src/main/java/org/apache/camel/quarkus/component/ssh/it/SshResource.java +++ b/integration-tests/ssh/src/main/java/org/apache/camel/quarkus/component/ssh/it/SshResource.java @@ -16,19 +16,22 @@ */ package org.apache.camel.quarkus.component.ssh.it; +import java.io.InputStream; import java.net.URI; import java.net.URISyntaxException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; -import jakarta.ws.rs.Produces; +import jakarta.ws.rs.*; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; +import org.apache.camel.CamelContext; +import org.apache.camel.ConsumerTemplate; +import org.apache.camel.Exchange; import org.apache.camel.ProducerTemplate; import org.eclipse.microprofile.config.inject.ConfigProperty; @@ -36,17 +39,32 @@ @ApplicationScoped public class SshResource { - private final String user = "test"; - private final String password = "password"; + public enum ServerType { + userPassword, user01Key, edKey + }; @ConfigProperty(name = "quarkus.ssh.host") - private String host; + String host; @ConfigProperty(name = "quarkus.ssh.port") - private String port; + String port; + @ConfigProperty(name = "quarkus.ssh.secured-port") + String securedPort; + @ConfigProperty(name = "quarkus.ssh.ed-port") + String edPort; + @ConfigProperty(name = "ssh.username") + String username; + @ConfigProperty(name = "ssh.password") + String password; + + @Inject + CamelContext camelContext; @Inject ProducerTemplate producerTemplate; + @Inject + ConsumerTemplate consumerTemplate; + @POST @Path("/file/{fileName}") @Consumes(MediaType.TEXT_PLAIN) @@ -55,7 +73,7 @@ public Response writeToFile(@PathParam("fileName") String fileName, String conte String sshWriteFileCommand = String.format("printf \"%s\" > %s", content, fileName); producerTemplate.sendBody( - String.format("ssh:%s:%s?username=%s&password=%s", host, port, user, password), + String.format("ssh:%s:%s?username=%s&password=%s", host, port, username, password), sshWriteFileCommand); return Response @@ -69,13 +87,60 @@ public Response writeToFile(@PathParam("fileName") String fileName, String conte public Response readFile(@PathParam("fileName") String fileName) throws URISyntaxException { String sshReadFileCommand = String.format("cat %s", fileName); - String content = producerTemplate.requestBody( - String.format("ssh:%s:%s?username=%s&password=%s", host, port, user, password), - sshReadFileCommand, + String content = consumerTemplate.receiveBody( + String.format("ssh:%s:%s?username=%s&password=%s&pollCommand=%s", host, port, username, password, + sshReadFileCommand), String.class); return Response .ok(content) .build(); } + + @POST + @Path("/send") + @Consumes(MediaType.APPLICATION_JSON) + public Map send(@QueryParam("command") String command, + @QueryParam("component") @DefaultValue("ssh") String component, + @QueryParam("serverType") @DefaultValue("userPassword") String serverType, + @QueryParam("pathSuffix") String pathSuffix, + Map headers) + throws URISyntaxException { + + var p = switch (ServerType.valueOf(serverType)) { + case userPassword -> port; + case edKey -> edPort; + case user01Key -> securedPort; + }; + + String url = String.format("%s:%s@%s:%s", component, username, host, p); + if (pathSuffix != null) { + url += "?" + pathSuffix; + } + Exchange exchange = producerTemplate.request(url, + e -> { + e.getIn().setHeaders(headers == null ? Collections.emptyMap() : headers); + e.getIn().setBody(command == null ? "" : command); + }); + + Map result = new HashMap<>(); + result.put("body", exchange.getMessage().getBody(String.class)); + result.putAll(exchange.getMessage().getHeaders().entrySet() + .stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + //convert inputStreams + entry -> String.valueOf(entry.getValue() instanceof InputStream + ? camelContext.getTypeConverter().convertTo(String.class, entry.getValue()) + : entry.getValue())))); + + return result; + } + + @Path("/sendToDirect/{direct}") + @POST + public String sendToDirect(@PathParam("direct") String direct, String body) throws Exception { + return producerTemplate.requestBody("direct:" + direct, body, String.class).trim(); + } + } diff --git a/integration-tests/ssh/src/main/java/org/apache/camel/quarkus/component/ssh/it/SshRoutes.java b/integration-tests/ssh/src/main/java/org/apache/camel/quarkus/component/ssh/it/SshRoutes.java new file mode 100644 index 000000000000..569c103cb5c5 --- /dev/null +++ b/integration-tests/ssh/src/main/java/org/apache/camel/quarkus/component/ssh/it/SshRoutes.java @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.quarkus.component.ssh.it; + +import java.nio.file.Paths; +import java.security.Security; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Named; +import net.i2p.crypto.eddsa.EdDSASecurityProvider; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.ssh.SshComponent; +import org.apache.sshd.common.keyprovider.FileKeyPairProvider; +import org.apache.sshd.common.keyprovider.KeyPairProvider; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +@ApplicationScoped +public class SshRoutes extends RouteBuilder { + + @ConfigProperty(name = "quarkus.ssh.host") + String host; + @ConfigProperty(name = "quarkus.ssh.port") + String port; + @ConfigProperty(name = "ssh.username") + String username; + @ConfigProperty(name = "ssh.password") + String password; + + @PostConstruct + public void init() { + Security.addProvider(new EdDSASecurityProvider()); + } + + @Override + public void configure() throws Exception { + // Route without SSL + from("direct:exampleProducer") + .toF("ssh://%s:%s@%s:%s", username, password, host, port); + + } + + /** + * We need to implement some conditional configuration of the {@link SshComponent} thus we create it + * programmatically and publish via CDI. + * + * @return a configured {@link SshComponent} + */ + @Named("ssh-with-key-provider") + SshComponent sshWithKeyProvider() throws IllegalAccessException, NoSuchFieldException, InstantiationException { + final SshComponent sshComponent = new SshComponent(); + sshComponent.setCamelContext(getContext()); + sshComponent.getConfiguration() + .setKeyPairProvider(new FileKeyPairProvider(Paths.get("target/certs/user01.key"))); + sshComponent.getConfiguration().setKeyType(KeyPairProvider.SSH_RSA); + return sshComponent; + } + + @Named("ssh-cert") + SshComponent sshCert() throws IllegalAccessException, NoSuchFieldException, InstantiationException { + final SshComponent sshComponent = new SshComponent(); + sshComponent.setCamelContext(getContext()); + sshComponent.getConfiguration().setKeyType(null); + return sshComponent; + } + +} diff --git a/integration-tests/ssh/src/main/resources/application.properties b/integration-tests/ssh/src/main/resources/application.properties new file mode 100644 index 000000000000..4c0c7f47f295 --- /dev/null +++ b/integration-tests/ssh/src/main/resources/application.properties @@ -0,0 +1,17 @@ +## --------------------------------------------------------------------------- +## Licensed to the Apache Software Foundation (ASF) under one or more +## contributor license agreements. See the NOTICE file distributed with +## this work for additional information regarding copyright ownership. +## The ASF licenses this file to You under the Apache License, Version 2.0 +## (the "License"); you may not use this file except in compliance with +## the License. You may obtain a copy of the License at +## +## http://www.apache.org/licenses/LICENSE-2.0 +## +## Unless required by applicable law or agreed to in writing, software +## distributed under the License is distributed on an "AS IS" BASIS, +## WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +## See the License for the specific language governing permissions and +## limitations under the License. +## --------------------------------------------------------------------------- +quarkus.native.resources.includes=edDSA/** diff --git a/integration-tests/ssh/src/test/java/org/apache/camel/quarkus/component/ssh/it/SshTest.java b/integration-tests/ssh/src/test/java/org/apache/camel/quarkus/component/ssh/it/SshTest.java index 4681ee0a9442..b30d4766f726 100644 --- a/integration-tests/ssh/src/test/java/org/apache/camel/quarkus/component/ssh/it/SshTest.java +++ b/integration-tests/ssh/src/test/java/org/apache/camel/quarkus/component/ssh/it/SshTest.java @@ -16,14 +16,27 @@ */ package org.apache.camel.quarkus.component.ssh.it; +import java.util.Map; + import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.junit.QuarkusTest; import io.restassured.RestAssured; import io.restassured.http.ContentType; +import io.smallrye.certs.Format; +import io.smallrye.certs.junit5.Certificate; +import org.apache.camel.component.ssh.SshConstants; +import org.apache.camel.quarkus.test.DisabledIfFipsMode; +import org.apache.camel.quarkus.test.support.certificate.TestCertificates; +import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +@TestCertificates(certificates = { + @Certificate(name = "user01", formats = { + Format.PEM }, password = "changeit"), + @Certificate(name = "eddsa", formats = { + Format.PEM }, password = "changeit") }) @QuarkusTest @QuarkusTestResource(SshTestResource.class) class SshTest { @@ -50,4 +63,79 @@ public void testWriteToSSHAndReadFromSSH() { assertEquals(fileContent, sshFileContent); } + @Test + public void testHeaders() { + RestAssured.given() + .contentType(ContentType.JSON) + .body(Map.of(SshConstants.USERNAME_HEADER, SshTestResource.USERNAME, SshConstants.PASSWORD_HEADER, + SshTestResource.PASSWORD)) + .queryParam("command", "wrong") + .post("/ssh/send/") + .then() + .statusCode(200) + .body("", Matchers.hasEntry(SshConstants.EXIT_VALUE, "127")) + .body("", Matchers.hasEntry(Matchers.matchesRegex(SshConstants.STDERR), + Matchers.containsString("command not found"))); + } + + @Test + public void testProducerInRoute() { + RestAssured.given() + .body("echo Hello World") + .post("/ssh/sendToDirect/exampleProducer") + .then() + .statusCode(200) + .body(Matchers.equalTo("Hello World")); + } + + @Test + public void testKeyProvider() { + RestAssured.given() + .contentType(ContentType.JSON) + .queryParam("component", "ssh-with-key-provider") + .queryParam("command", "echo test") + .queryParam("serverType", "user01Key") + .post("/ssh/send") + .then() + .statusCode(200) + .body("", Matchers.hasEntry(SshConstants.EXIT_VALUE, "0")) + //expecting error from command factory + .body("", Matchers.hasEntry(SshConstants.STDERR, "Expected Error:echo test")); + } + + @Test + public void testCertificate() { + RestAssured.given() + .contentType(ContentType.JSON) + .queryParam("component", "ssh-cert") + .queryParam("command", "echo test") + .queryParam("serverType", "user01Key") + .queryParam("pathSuffix", "certResource=file:target/certs/user01.key&certResourcePassword=changeit") + .post("/ssh/send") + .then() + .statusCode(200) + .body("", Matchers.hasEntry(SshConstants.EXIT_VALUE, "0")) + //expecting error from command factory + .body("", Matchers.hasEntry(SshConstants.STDERR, "Expected Error:echo test")); + } + + @DisabledIfFipsMode //ED25519 keys are not allowed in FIPS mode + @Test + public void testProducerWithEdDSAKeyType() { + RestAssured.given() + .contentType(ContentType.JSON) + .queryParam("command", "echo Hello!") + .queryParam("serverType", "edKey") + .queryParam("pathSuffix", + "timeout=3000&knownHostsResource=/edDSA/known_hosts_eddsa&failOnUnknownHost=true") + .body(Map.of(SshConstants.USERNAME_HEADER, SshTestResource.USERNAME, SshConstants.PASSWORD_HEADER, + SshTestResource.PASSWORD)) + .post("/ssh/send") + .then() + .statusCode(200) + .body("", Matchers.hasEntry(SshConstants.EXIT_VALUE, "0")) + //expecting error from command factory + .body("", Matchers.hasEntry(SshConstants.STDERR, "Expected Error:echo Hello!")); + } + } diff --git a/integration-tests/ssh/src/test/java/org/apache/camel/quarkus/component/ssh/it/SshTestResource.java b/integration-tests/ssh/src/test/java/org/apache/camel/quarkus/component/ssh/it/SshTestResource.java index 56db5ec4728f..f1946b57d3ff 100644 --- a/integration-tests/ssh/src/test/java/org/apache/camel/quarkus/component/ssh/it/SshTestResource.java +++ b/integration-tests/ssh/src/test/java/org/apache/camel/quarkus/component/ssh/it/SshTestResource.java @@ -16,10 +16,15 @@ */ package org.apache.camel.quarkus.component.ssh.it; +import java.nio.file.Paths; +import java.util.LinkedList; +import java.util.List; import java.util.Map; import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; -import org.apache.camel.util.CollectionHelper; +import org.apache.camel.quarkus.test.AvailablePortFinder; +import org.apache.sshd.common.keyprovider.FileKeyPairProvider; +import org.apache.sshd.server.SshServer; import org.eclipse.microprofile.config.ConfigProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,8 +38,12 @@ public class SshTestResource implements QuarkusTestResourceLifecycleManager { private static final int SSH_PORT = 2222; private static final String SSH_IMAGE = ConfigProvider.getConfig().getValue("openssh-server.container.image", String.class); + static final String USERNAME = "user01"; + static final String PASSWORD = "changeit"; private GenericContainer container; + protected List sshds = new LinkedList<>(); + protected int securedPort, edPort; @Override public Map start() { @@ -45,8 +54,8 @@ public Map start() { container = new GenericContainer(SSH_IMAGE) .withExposedPorts(SSH_PORT) .withEnv("PASSWORD_ACCESS", "true") - .withEnv("USER_NAME", "test") - .withEnv("USER_PASSWORD", "password") + .withEnv("USER_NAME", USERNAME) + .withEnv("USER_PASSWORD", PASSWORD) .waitingFor(Wait.forListeningPort()); container.start(); @@ -54,24 +63,68 @@ public Map start() { LOGGER.info("Started SSH container to {}:{}", container.getHost(), container.getMappedPort(SSH_PORT).toString()); - return CollectionHelper.mapOf( - "quarkus.ssh.host", - container.getHost(), - "quarkus.ssh.port", - container.getMappedPort(SSH_PORT).toString()); + securedPort = AvailablePortFinder.getNextAvailable(); + + var sshd = SshServer.setUpDefaultServer(); + sshd.setPort(securedPort); + sshd.setKeyPairProvider(new FileKeyPairProvider(Paths.get(getHostKey()))); + sshd.setCommandFactory(new TestEchoCommandFactory()); + sshd.setPasswordAuthenticator((username, password, session) -> true); + sshd.setPublickeyAuthenticator((username, key, session) -> true); + sshd.start(); + + sshds.add(sshd); + + edPort = AvailablePortFinder.getNextAvailable(); + + sshd = SshServer.setUpDefaultServer(); + sshd.setPort(edPort); + sshd.setKeyPairProvider(new FileKeyPairProvider(Paths.get("target/classes/edDSA/key_ed25519.pem"))); + sshd.setCommandFactory(new TestEchoCommandFactory()); + sshd.setPasswordAuthenticator((username, password, session) -> true); + sshd.setPublickeyAuthenticator((username, key, session) -> true); + sshd.start(); + + sshds.add(sshd); + + LOGGER.info("Started SSHD server to {}:{}", container.getHost(), + securedPort); + + return Map.of( + "quarkus.ssh.host", "localhost", + "quarkus.ssh.port", container.getMappedPort(SSH_PORT).toString(), + "quarkus.ssh.secured-port", securedPort + "", + "quarkus.ssh.ed-port", edPort + "", + "ssh.username", USERNAME, + "ssh.password", PASSWORD); } catch (Exception e) { throw new RuntimeException(e); } } + //todo proper path (no target) + protected String getHostKey() { + //todo test + // return "target/classes/hostkey.pem"; + return "target/certs/user01.key"; + } + @Override public void stop() { - LOGGER.info("Stopping SSH container"); + LOGGER.info("Stopping SSH container and servers"); try { if (container != null) { container.stop(); } + sshds.stream().forEach(s -> { + try { + s.stop(true); + Thread.sleep(50); + } catch (Exception e) { + // ignored + } + }); } catch (Exception e) { // ignored } diff --git a/integration-tests/ssh/src/test/java/org/apache/camel/quarkus/component/ssh/it/TestEchoCommandFactory.java b/integration-tests/ssh/src/test/java/org/apache/camel/quarkus/component/ssh/it/TestEchoCommandFactory.java new file mode 100644 index 000000000000..26af1472667f --- /dev/null +++ b/integration-tests/ssh/src/test/java/org/apache/camel/quarkus/component/ssh/it/TestEchoCommandFactory.java @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.quarkus.component.ssh.it; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.concurrent.CountDownLatch; + +import org.apache.sshd.server.Environment; +import org.apache.sshd.server.ExitCallback; +import org.apache.sshd.server.channel.ChannelSession; +import org.apache.sshd.server.command.Command; +import org.apache.sshd.server.command.CommandFactory; + +public class TestEchoCommandFactory implements CommandFactory { + + @Override + public Command createCommand(ChannelSession channelSession, String command) { + return new TestEchoCommand(command); + } + + public static class TestEchoCommand extends EchoCommand { + public static CountDownLatch latch = new CountDownLatch(1); + + public TestEchoCommand(String command) { + super(command); + } + + @Override + public void destroy(ChannelSession channelSession) throws Exception { + if (latch != null) { + latch.countDown(); + } + super.destroy(channelSession); + } + } + + protected static class EchoCommand implements Command, Runnable { + private String command; + private OutputStream out; + private OutputStream err; + private ExitCallback callback; + private Thread thread; + + public EchoCommand(String command) { + this.command = command; + } + + @Override + public void setInputStream(InputStream in) { + } + + @Override + public void setOutputStream(OutputStream out) { + this.out = out; + } + + @Override + public void setErrorStream(OutputStream err) { + this.err = err; + } + + @Override + public void setExitCallback(ExitCallback callback) { + this.callback = callback; + } + + @Override + public void start(ChannelSession channelSession, Environment environment) throws IOException { + thread = new Thread(this, "EchoCommand"); + thread.start(); + } + + @Override + public void destroy(ChannelSession channelSession) throws Exception { + // noop + } + + @Override + public void run() { + boolean succeeded = true; + String message = null; + try { + // we set the error with the same command message + err.write("Expected Error:".getBytes()); + err.write(command.getBytes()); + err.flush(); + out.write(command.getBytes()); + out.flush(); + } catch (Exception e) { + succeeded = false; + message = e.toString(); + } finally { + if (succeeded) { + callback.onExit(0); + } else { + callback.onExit(1, message); + } + } + } + } +}