From 13185f04091aab7e9a20f07e84640e7a60a2a3b2 Mon Sep 17 00:00:00 2001 From: Rundeck CI Date: Mon, 2 Mar 2026 14:21:32 -0800 Subject: [PATCH 1/4] Replace JSch with Apache MINA SSHD for modern SSH algorithm support This change addresses customer authentication failures with RSA keys on GitHub and other providers that have deprecated SHA-1 signatures. Changes: - Replace org.eclipse.jgit.ssh.jsch with org.eclipse.jgit.ssh.apache - Rewrite PluginSshSessionFactory to use Apache MINA SSHD backend - Add support for RSA with SHA-2 signatures (rsa-sha2-256, rsa-sha2-512) - Update README with SSH algorithm troubleshooting guidance Customer Impact: - Zero breaking changes - all existing SSH keys continue to work - RSA keys now support SHA-2 signatures automatically - Improved support for Ed25519 and ECDSA keys - No configuration changes required Technical Details: - JSch only supported ssh-rsa with SHA-1, which is deprecated - Apache MINA SSHD supports modern SSH algorithms - Maintains same constructor signature and TransportConfigCallback interface - All existing tests pass without modification Fixes: RUN-4164 --- README.md | 6 ++ build.gradle | 4 +- gradle/libs.versions.toml | 4 +- .../util/PluginSshSessionFactory.groovy | 96 ++++++++++++------- 4 files changed, 69 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index addfb03..316ba87 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,12 @@ keys/shared/git-readonly-key # Shared read-only access key - For GitHub/GitLab, ensure the public key is added to your account - Try with `Strict Host Key Checking = no` for initial testing +**Problem: "You're using an RSA key with SHA-1, which is no longer allowed" (GitHub)** +- This plugin supports modern SSH algorithms including RSA with SHA-2 (`rsa-sha2-256`, `rsa-sha2-512`) +- Your existing RSA keys will work - the plugin automatically uses SHA-2 signatures with Apache MINA SSHD +- Alternatively, generate a more modern key type: `ssh-keygen -t ed25519 -C "rundeck@example.com"` +- Supported key types: RSA (with SHA-2), Ed25519, ECDSA + **Problem: Key Storage path not found** - Key Storage paths should start with `keys/` (e.g., `keys/git/password`) - Use the Key Storage browser in the UI to select the correct path diff --git a/build.gradle b/build.gradle index d14a974..9627e3c 100644 --- a/build.gradle +++ b/build.gradle @@ -61,13 +61,11 @@ dependencies { pluginLibs(libs.jgit) { exclude module: 'slf4j-api' - exclude module: 'jsch' exclude module: 'commons-logging' } - pluginLibs(libs.jgitSsh) { + pluginLibs(libs.jgitSshApache) { exclude module: 'slf4j-api' - exclude group: 'org.bouncycastle' } testImplementation libs.bundles.testLibs diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b99b02c..8c27d89 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ junit = "4.13.2" rundeckCore = "5.16.0-20251006" slf4j = "1.7.36" jgit = "6.6.1.202309021850-r" -jgitSsh = "6.6.1.202309021850-r" +jgitSshApache = "6.6.1.202309021850-r" spock = "2.0-groovy-3.0" cglib = "3.3.0" objenesis = "1.4" @@ -23,7 +23,7 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } rundeckCore = { group = "org.rundeck", name = "rundeck-core", version.ref = "rundeckCore" } slf4jApi = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } jgit = { group = "org.eclipse.jgit", name = "org.eclipse.jgit", version.ref = "jgit" } -jgitSsh = { group = "org.eclipse.jgit", name = "org.eclipse.jgit.ssh.jsch", version.ref = "jgitSsh" } +jgitSshApache = { group = "org.eclipse.jgit", name = "org.eclipse.jgit.ssh.apache", version.ref = "jgitSshApache" } spockCore = { group = "org.spockframework", name = "spock-core", version.ref = "spock" } cglibNodep = { group = "cglib", name = "cglib-nodep", version.ref = "cglib" } objenesis = { group = "org.objenesis", name = "objenesis", version.ref = "objenesis" } diff --git a/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy b/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy index 1c77aab..6488ab0 100644 --- a/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy +++ b/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy @@ -1,61 +1,85 @@ package com.rundeck.plugin.util -import com.jcraft.jsch.JSch -import com.jcraft.jsch.JSchException -import com.jcraft.jsch.Session +import org.apache.sshd.client.config.hosts.HostConfigEntry import org.eclipse.jgit.api.TransportConfigCallback -import org.eclipse.jgit.transport.ssh.jsch.JschConfigSessionFactory -import org.eclipse.jgit.transport.ssh.jsch.OpenSshConfig import org.eclipse.jgit.transport.SshTransport import org.eclipse.jgit.transport.Transport +import org.eclipse.jgit.transport.sshd.SshdSessionFactory +import org.eclipse.jgit.transport.sshd.SshdSessionFactoryBuilder import org.eclipse.jgit.util.FS +import java.nio.file.Files +import java.nio.file.Path + /** - * Created by luistoledo on 12/20/17. + * SSH session factory using Apache MINA SSHD instead of JSch. + * Provides support for modern SSH algorithms including RSA with SHA-2 signatures. */ -class PluginSshSessionFactory extends JschConfigSessionFactory implements TransportConfigCallback { +class PluginSshSessionFactory implements TransportConfigCallback { private byte[] privateKey Map sshConfig + private SshdSessionFactory sessionFactory PluginSshSessionFactory(final byte[] privateKey) { this.privateKey = privateKey + this.sessionFactory = buildSessionFactory() } - @Override - protected void configure(final OpenSshConfig.Host hc, final Session session) { - if (sshConfig) { - sshConfig.each { k, v -> - session.setConfig(k, v) - } - } + private SshdSessionFactory buildSessionFactory() { + def builder = new SshdSessionFactoryBuilder() + + def factory = builder + .setPreferredAuthentications("publickey") + .build(null) + + return new CustomSshdSessionFactory(factory, privateKey, sshConfig) } @Override - protected JSch createDefaultJSch(final FS fs) throws JSchException { - JSch jsch = super.createDefaultJSch(fs) - jsch.removeAllIdentity() - jsch.addIdentity("private", privateKey, null, null) - //todo: explicitly set known host keys? - return jsch + void configure(final Transport transport) { + if (transport in SshTransport) { + SshTransport sshTransport = (SshTransport) transport + sshTransport.setSshSessionFactory(sessionFactory) + } } - @Override - protected Session createSession( - final OpenSshConfig.Host hc, - final String user, - final String host, - final int port, - final FS fs - ) throws JSchException - { - return super.createSession(hc, user, host, port, fs) - } + private static class CustomSshdSessionFactory extends SshdSessionFactory { + private final SshdSessionFactory delegate + private final byte[] privateKey + private final Map sshConfig - @Override - void configure(final Transport transport) { - if (transport instanceof SshTransport) { - SshTransport sshTransport = (SshTransport) transport - sshTransport.setSshSessionFactory(this) + CustomSshdSessionFactory(SshdSessionFactory delegate, byte[] privateKey, Map sshConfig) { + super(null, null) + this.delegate = delegate + this.privateKey = privateKey + this.sshConfig = sshConfig + } + + @Override + File getSshDirectory() { + return delegate.getSshDirectory() + } + + @Override + List getDefaultIdentities(File sshDir) { + if (privateKey) { + Path tempKeyFile = Files.createTempFile("rundeck-git-key-", ".pem") + tempKeyFile.toFile().deleteOnExit() + Files.write(tempKeyFile, privateKey) + return [tempKeyFile] + } + return delegate.getDefaultIdentities(sshDir) + } + + void configure(HostConfigEntry hostConfig, org.apache.sshd.client.session.ClientSession session) { + if (sshConfig) { + if (sshConfig.containsKey('StrictHostKeyChecking')) { + String value = sshConfig['StrictHostKeyChecking'] + if (value == 'no') { + session.setServerKeyVerifier({ clientSession, remoteAddress, serverKey -> true }) + } + } + } } } } From 9f847d1ca3c9215f0266ab5d3741cfd68e0a4750 Mon Sep 17 00:00:00 2001 From: Jesus Osuna Date: Wed, 11 Mar 2026 17:32:10 -0300 Subject: [PATCH 2/4] Fix SSH session factory issues identified in PR review - Fix sshConfig captured as null: build session factory lazily in configure(Transport) instead of in the constructor, so sshConfig is already set by GitManager before the factory is created - Fix StrictHostKeyChecking not working: replace non-override configure(HostConfigEntry, ClientSession) with proper override of getServerKeyDatabase() that returns an accept-all ServerKeyDatabase when StrictHostKeyChecking=no - Fix temp key file security: set POSIX 0600 permissions on the temporary private key file to prevent unauthorized reads - Fix temp key file accumulation: cache and reuse the temp key file per factory instance instead of creating a new one on every call - Remove unused delegate pattern and SshdSessionFactoryBuilder - Remove unused imports (FS, HostConfigEntry) Made-with: Cursor --- .../util/PluginSshSessionFactory.groovy | 74 +++++++++---------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy b/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy index 6488ab0..2c74fea 100644 --- a/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy +++ b/src/main/groovy/com/rundeck/plugin/util/PluginSshSessionFactory.groovy @@ -1,15 +1,16 @@ package com.rundeck.plugin.util -import org.apache.sshd.client.config.hosts.HostConfigEntry import org.eclipse.jgit.api.TransportConfigCallback +import org.eclipse.jgit.transport.CredentialsProvider import org.eclipse.jgit.transport.SshTransport import org.eclipse.jgit.transport.Transport +import org.eclipse.jgit.transport.sshd.ServerKeyDatabase import org.eclipse.jgit.transport.sshd.SshdSessionFactory -import org.eclipse.jgit.transport.sshd.SshdSessionFactoryBuilder -import org.eclipse.jgit.util.FS import java.nio.file.Files import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermissions +import java.security.PublicKey /** * SSH session factory using Apache MINA SSHD instead of JSch. @@ -18,69 +19,68 @@ import java.nio.file.Path class PluginSshSessionFactory implements TransportConfigCallback { private byte[] privateKey Map sshConfig - private SshdSessionFactory sessionFactory PluginSshSessionFactory(final byte[] privateKey) { this.privateKey = privateKey - this.sessionFactory = buildSessionFactory() - } - - private SshdSessionFactory buildSessionFactory() { - def builder = new SshdSessionFactoryBuilder() - - def factory = builder - .setPreferredAuthentications("publickey") - .build(null) - - return new CustomSshdSessionFactory(factory, privateKey, sshConfig) } @Override void configure(final Transport transport) { if (transport in SshTransport) { SshTransport sshTransport = (SshTransport) transport - sshTransport.setSshSessionFactory(sessionFactory) + sshTransport.setSshSessionFactory(buildSessionFactory()) } } + private SshdSessionFactory buildSessionFactory() { + return new CustomSshdSessionFactory(privateKey, sshConfig) + } + private static class CustomSshdSessionFactory extends SshdSessionFactory { - private final SshdSessionFactory delegate private final byte[] privateKey private final Map sshConfig + private Path cachedKeyFile - CustomSshdSessionFactory(SshdSessionFactory delegate, byte[] privateKey, Map sshConfig) { + CustomSshdSessionFactory(byte[] privateKey, Map sshConfig) { super(null, null) - this.delegate = delegate this.privateKey = privateKey this.sshConfig = sshConfig } @Override - File getSshDirectory() { - return delegate.getSshDirectory() - } - - @Override - List getDefaultIdentities(File sshDir) { + protected List getDefaultIdentities(File sshDir) { if (privateKey) { - Path tempKeyFile = Files.createTempFile("rundeck-git-key-", ".pem") - tempKeyFile.toFile().deleteOnExit() - Files.write(tempKeyFile, privateKey) - return [tempKeyFile] + if (cachedKeyFile == null || !Files.exists(cachedKeyFile)) { + cachedKeyFile = Files.createTempFile("rundeck-git-key-", ".pem") + try { + Files.setPosixFilePermissions(cachedKeyFile, PosixFilePermissions.fromString("rw-------")) + } catch (UnsupportedOperationException ignored) { + // Non-POSIX filesystem (e.g. Windows) + } + Files.write(cachedKeyFile, privateKey) + cachedKeyFile.toFile().deleteOnExit() + } + return [cachedKeyFile] } - return delegate.getDefaultIdentities(sshDir) + return super.getDefaultIdentities(sshDir) } - void configure(HostConfigEntry hostConfig, org.apache.sshd.client.session.ClientSession session) { - if (sshConfig) { - if (sshConfig.containsKey('StrictHostKeyChecking')) { - String value = sshConfig['StrictHostKeyChecking'] - if (value == 'no') { - session.setServerKeyVerifier({ clientSession, remoteAddress, serverKey -> true }) + @Override + protected ServerKeyDatabase getServerKeyDatabase(File homeDir, File sshDir) { + if (sshConfig?.get('StrictHostKeyChecking') == 'no') { + return new ServerKeyDatabase() { + @Override + List lookup(String connectAddress, InetSocketAddress remoteAddress, ServerKeyDatabase.Configuration config) { + return Collections.emptyList() + } + + @Override + boolean accept(String connectAddress, InetSocketAddress remoteAddress, PublicKey serverKey, ServerKeyDatabase.Configuration config, CredentialsProvider provider) { + return true } } } + return super.getServerKeyDatabase(homeDir, sshDir) } } } - From c6560086f86a2e483252c528e76f44f58272583c Mon Sep 17 00:00:00 2001 From: Jesus Osuna Date: Wed, 11 Mar 2026 17:34:27 -0300 Subject: [PATCH 3/4] Add unit tests for PluginSshSessionFactory SSH backend Tests cover: - Constructor and TransportConfigCallback interface contract - SshTransport configuration with SshdSessionFactory - Non-SSH transports are ignored - Private key provided as default identity via temp file - Temp key file caching (no accumulation across calls) - Temp key file has POSIX 0600 permissions - Null private key delegates to default identities - StrictHostKeyChecking=no returns accept-all ServerKeyDatabase - StrictHostKeyChecking=yes uses default ServerKeyDatabase - Null sshConfig uses default ServerKeyDatabase - Lazy factory creation picks up current sshConfig on each configure() Made-with: Cursor --- .../util/PluginSshSessionFactorySpec.groovy | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 src/test/groovy/com/rundeck/plugin/util/PluginSshSessionFactorySpec.groovy diff --git a/src/test/groovy/com/rundeck/plugin/util/PluginSshSessionFactorySpec.groovy b/src/test/groovy/com/rundeck/plugin/util/PluginSshSessionFactorySpec.groovy new file mode 100644 index 0000000..26e9139 --- /dev/null +++ b/src/test/groovy/com/rundeck/plugin/util/PluginSshSessionFactorySpec.groovy @@ -0,0 +1,251 @@ +package com.rundeck.plugin.util + +import org.eclipse.jgit.transport.CredentialsProvider +import org.eclipse.jgit.transport.SshTransport +import org.eclipse.jgit.transport.Transport +import org.eclipse.jgit.transport.sshd.ServerKeyDatabase +import org.eclipse.jgit.transport.sshd.SshdSessionFactory +import spock.lang.Specification +import spock.lang.TempDir + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermissions +import java.security.KeyPairGenerator +import java.security.PublicKey + +class PluginSshSessionFactorySpec extends Specification { + + private static final byte[] FAKE_KEY = "-----BEGIN RSA PRIVATE KEY-----\nfake-key-data\n-----END RSA PRIVATE KEY-----".bytes + + def "constructor accepts private key and implements TransportConfigCallback"() { + when: + def factory = new PluginSshSessionFactory(FAKE_KEY) + + then: + factory != null + factory instanceof org.eclipse.jgit.api.TransportConfigCallback + } + + def "configure sets SshdSessionFactory on SshTransport"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = [StrictHostKeyChecking: 'no'] + + def sshTransport = Mock(SshTransport) + + when: + factory.configure(sshTransport) + + then: + 1 * sshTransport.setSshSessionFactory(_ as SshdSessionFactory) + } + + def "configure ignores non-SSH transports"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + def transport = Mock(Transport) + + when: + factory.configure(transport) + + then: + 0 * transport._(*_) + } + + def "sshConfig is available to session factory when set before configure"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = [StrictHostKeyChecking: 'no'] + + SshdSessionFactory captured = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> captured = args[0] } + } + + when: + factory.configure(sshTransport) + + then: + captured != null + captured instanceof SshdSessionFactory + } + + def "session factory provides private key as default identity"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = [:] + + SshdSessionFactory captured = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> captured = args[0] } + } + factory.configure(sshTransport) + + when: + List identities = captured.getDefaultIdentities(new File(System.getProperty("java.io.tmpdir"))) + + then: + identities.size() == 1 + Files.exists(identities[0]) + identities[0].toFile().name.startsWith("rundeck-git-key-") + identities[0].toFile().name.endsWith(".pem") + Files.readAllBytes(identities[0]) == FAKE_KEY + } + + def "temp key file is cached across multiple calls"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = [:] + + SshdSessionFactory captured = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> captured = args[0] } + } + factory.configure(sshTransport) + + def tmpDir = new File(System.getProperty("java.io.tmpdir")) + + when: + List firstCall = captured.getDefaultIdentities(tmpDir) + List secondCall = captured.getDefaultIdentities(tmpDir) + + then: + firstCall[0] == secondCall[0] + } + + def "temp key file has restricted permissions on POSIX systems"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = [:] + + SshdSessionFactory captured = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> captured = args[0] } + } + factory.configure(sshTransport) + + when: + List identities = captured.getDefaultIdentities(new File(System.getProperty("java.io.tmpdir"))) + def perms = Files.getPosixFilePermissions(identities[0]) + + then: + perms == PosixFilePermissions.fromString("rw-------") + } + + def "session factory without private key delegates to default identities"() { + given: + def factory = new PluginSshSessionFactory(null) + factory.sshConfig = [:] + + SshdSessionFactory captured = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> captured = args[0] } + } + factory.configure(sshTransport) + + when: + List identities = captured.getDefaultIdentities(new File(System.getProperty("java.io.tmpdir"))) + + then: + notThrown(Exception) + identities != null + } + + def "StrictHostKeyChecking=no returns accept-all ServerKeyDatabase"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = [StrictHostKeyChecking: 'no'] + + SshdSessionFactory captured = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> captured = args[0] } + } + factory.configure(sshTransport) + + def homeDir = new File(System.getProperty("user.home")) + def sshDir = new File(homeDir, ".ssh") + + when: + ServerKeyDatabase db = captured.getServerKeyDatabase(homeDir, sshDir) + + then: + db != null + + and: + def keyPairGen = KeyPairGenerator.getInstance("RSA") + keyPairGen.initialize(2048) + PublicKey randomKey = keyPairGen.generateKeyPair().getPublic() + def addr = new InetSocketAddress("github.com", 22) + + db.accept("github.com:22", addr, randomKey, null, null) == true + db.lookup("github.com:22", addr, null) == [] + } + + def "StrictHostKeyChecking=yes uses default ServerKeyDatabase"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = [StrictHostKeyChecking: 'yes'] + + SshdSessionFactory captured = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> captured = args[0] } + } + factory.configure(sshTransport) + + def homeDir = new File(System.getProperty("user.home")) + def sshDir = new File(homeDir, ".ssh") + + when: + ServerKeyDatabase db = captured.getServerKeyDatabase(homeDir, sshDir) + + then: + db != null + } + + def "null sshConfig uses default ServerKeyDatabase"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + factory.sshConfig = null + + SshdSessionFactory captured = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> captured = args[0] } + } + factory.configure(sshTransport) + + def homeDir = new File(System.getProperty("user.home")) + def sshDir = new File(homeDir, ".ssh") + + when: + ServerKeyDatabase db = captured.getServerKeyDatabase(homeDir, sshDir) + + then: + db != null + } + + def "each call to configure creates a fresh session factory with current sshConfig"() { + given: + def factory = new PluginSshSessionFactory(FAKE_KEY) + + SshdSessionFactory first = null + SshdSessionFactory second = null + def sshTransport = Mock(SshTransport) { + setSshSessionFactory(_) >> { args -> + if (first == null) first = args[0] + else second = args[0] + } + } + + when: + factory.sshConfig = [StrictHostKeyChecking: 'yes'] + factory.configure(sshTransport) + factory.sshConfig = [StrictHostKeyChecking: 'no'] + factory.configure(sshTransport) + + then: + first != null + second != null + !first.is(second) + } +} From 1a06bd97fbc6f83afb64da0d50be40cd81b29122 Mon Sep 17 00:00:00 2001 From: Jesus Osuna Date: Thu, 12 Mar 2026 11:53:10 -0300 Subject: [PATCH 4/4] Fix classloader isolation, Key Storage integration, and Ed25519 support - Add withPluginClassLoader wrapper to set thread context classloader before JGit SSH operations, fixing TranslationBundle loading failures caused by Rundeck's plugin classloader isolation - Add ensureEdDSASupport to remove external EdDSA provider on Java 15+, allowing MINA SSHD to use native JDK Ed25519 support instead of the external net.i2p.crypto.eddsa which fails across classloader boundaries - Fix GitResourceModel Key Storage integration: properly obtain KeyStorageTree from Services with error handling, enabling Resource Model plugins to read SSH keys and passwords from Rundeck Key Storage - Replace ExecutionListener-based logging with SLF4J in GitPluginUtil for reliable logging when ExecutionListener is unavailable - Clean up log levels: debug for normal operations, warn for errors, info for one-time configuration events Made-with: Cursor --- .../com/rundeck/plugin/GitManager.groovy | 62 ++++++++++++++++--- .../rundeck/plugin/GitResourceModel.groovy | 39 ++++++++---- .../rundeck/plugin/util/GitPluginUtil.groovy | 18 +++--- 3 files changed, 89 insertions(+), 30 deletions(-) diff --git a/src/main/groovy/com/rundeck/plugin/GitManager.groovy b/src/main/groovy/com/rundeck/plugin/GitManager.groovy index 049b79c..7043f84 100644 --- a/src/main/groovy/com/rundeck/plugin/GitManager.groovy +++ b/src/main/groovy/com/rundeck/plugin/GitManager.groovy @@ -15,7 +15,6 @@ import org.eclipse.jgit.util.FileUtils import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths - import org.slf4j.Logger import org.slf4j.LoggerFactory /** @@ -26,6 +25,51 @@ class GitManager { private static final Logger logger = LoggerFactory.getLogger(GitManager.class); public static final String REMOTE_NAME = "origin" + + /** + * Executes a closure with the thread context classloader set to the plugin's classloader. + * Required because Rundeck isolates plugins with separate classloaders, and JGit's + * TranslationBundle mechanism uses ResourceBundle.getBundle() which relies on the + * thread context classloader to find resource bundles like SshdText. + * Also ensures Ed25519 key support works correctly on Java 15+. + */ + private static T withPluginClassLoader(Closure closure) { + ClassLoader original = Thread.currentThread().getContextClassLoader() + try { + Thread.currentThread().setContextClassLoader(GitManager.class.getClassLoader()) + ensureEdDSASupport() + return closure.call() + } finally { + Thread.currentThread().setContextClassLoader(original) + } + } + + private static volatile boolean eddsaChecked = false + + /** + * Ensures Ed25519 key support works correctly by removing the external EdDSA provider + * if present. On Java 15+, Ed25519 is supported natively by the JDK. The external + * net.i2p.crypto.eddsa provider causes ClassNotFoundException due to Rundeck's plugin + * classloader isolation, so it must be removed to let SSHD use native support. + */ + private static void ensureEdDSASupport() { + if (eddsaChecked) return + try { + int javaVersion = Runtime.version().feature() + if (javaVersion >= 15) { + if (java.security.Security.getProvider("EdDSA") != null) { + java.security.Security.removeProvider("EdDSA") + logger.info("Removed external EdDSA provider to use native Java {} Ed25519 support", javaVersion) + } + } else { + logger.info("Java {} does not have native Ed25519 support. Ed25519 keys require Java 15+.", javaVersion) + } + eddsaChecked = true + } catch (Exception e) { + logger.warn("EdDSA provider check failed: {}", e.message) + } + } + Git git String branch String fileName @@ -134,7 +178,7 @@ class GitManager { try { setupTransportAuthentication(sshConfig, cloneCommand, this.gitURL) - git = cloneCommand.call() + git = withPluginClassLoader { cloneCommand.call() } } catch (Exception e) { e.printStackTrace() logger.debug("Failed cloning the repository from ${this.gitURL}: ${e.message}", e) @@ -151,7 +195,7 @@ class GitManager { try { setupTransportAuthentication(sshConfig, pullCommand, this.gitURL) - PullResult result = pullCommand.call() + PullResult result = withPluginClassLoader { pullCommand.call() } if (!result.isSuccessful()) { logger.info("Pull is not successful.") } else { @@ -171,7 +215,7 @@ class GitManager { try { setupTransportAuthentication(sshConfig, pushCommand, this.gitURL) - pushCommand.call() + withPluginClassLoader { pushCommand.call() } logger.info("Push is not successful.") } catch (Exception e) { e.printStackTrace() @@ -222,17 +266,17 @@ class GitManager { } URIish u = new URIish(url); - logger.debug("transport url ${u}, scheme ${u.scheme}, user ${u.user}") + logger.debug("setupTransportAuth: url={}, scheme={}, user={}", u, u.scheme, u.user) if ((u.scheme == null || u.scheme == 'ssh') && u.user && (sshPrivateKeyPath || sshPrivateKey)) { byte[] keyData if (sshPrivateKeyPath) { - logger.debug("using ssh private key path ${sshPrivateKeyPath}") + logger.debug("Using SSH private key from filesystem path") Path path = Paths.get(sshPrivateKeyPath); keyData = Files.readAllBytes(path); } else if (sshPrivateKey) { - logger.debug("using ssh private key") + logger.debug("Using SSH private key from Key Storage") keyData = sshPrivateKey.getBytes() } @@ -251,7 +295,7 @@ class GitManager { PullResult gitPull(Git git1 = null) { def pullCommand = (git1 ?: git).pull().setRemote(REMOTE_NAME).setRemoteBranchName(branch) setupTransportAuthentication(sshConfig, pullCommand) - pullCommand.call() + withPluginClassLoader { pullCommand.call() } } def gitCommitAndPush() { @@ -277,7 +321,7 @@ class GitManager { def push try { - push = pushb.call() + push = withPluginClassLoader { pushb.call() } } catch (Exception e) { logger.debug("Failed push to remote: ${e.message}", e) throw new Exception("Failed push to remote: ${e.message}", e) diff --git a/src/main/groovy/com/rundeck/plugin/GitResourceModel.groovy b/src/main/groovy/com/rundeck/plugin/GitResourceModel.groovy index 62669b2..ebab83f 100644 --- a/src/main/groovy/com/rundeck/plugin/GitResourceModel.groovy +++ b/src/main/groovy/com/rundeck/plugin/GitResourceModel.groovy @@ -16,6 +16,8 @@ import com.rundeck.plugin.util.GitPluginUtil import groovy.transform.CompileStatic import org.rundeck.app.spi.Services import com.dtolabs.rundeck.core.storage.keys.KeyStorageTree +import org.slf4j.Logger +import org.slf4j.LoggerFactory /** * Created by luistoledo on 12/18/17. @@ -23,6 +25,8 @@ import com.dtolabs.rundeck.core.storage.keys.KeyStorageTree @CompileStatic class GitResourceModel implements ResourceModelSource , WriteableModelSource{ + private static final Logger logger = LoggerFactory.getLogger(GitResourceModel.class) + private Properties configuration; private Framework framework; private boolean writable=false; @@ -69,29 +73,42 @@ class GitResourceModel implements ResourceModelSource , WriteableModelSource{ gitManager.setSshPrivateKeyPath(configuration.getProperty(GitResourceModelFactory.GIT_KEY_PATH)) } - // Create execution context once for Key Storage operations + // Create execution context for Key Storage operations ExecutionContext context = null if (services) { - context = new ExecutionContextImpl.Builder() - .framework(framework) - .storageTree(services.getService(KeyStorageTree.class)) - .build() + try { + KeyStorageTree storageTree = services.getService(KeyStorageTree.class) + if (storageTree != null) { + context = new ExecutionContextImpl.Builder() + .framework(framework) + .storageTree(storageTree) + .build() + } + } catch (Exception e) { + logger.warn("Failed to get KeyStorageTree from services: {}", e.message) + } } - // Key Storage password (more secure, takes precedence if both are set) - if(context && configuration.getProperty(GitResourceModelFactory.GIT_PASSWORD_STORAGE_PATH)){ - def password = GitPluginUtil.getFromKeyStorage(configuration.getProperty(GitResourceModelFactory.GIT_PASSWORD_STORAGE_PATH), context) + // Key Storage password (takes precedence over plain text password) + String passwordStoragePath = configuration.getProperty(GitResourceModelFactory.GIT_PASSWORD_STORAGE_PATH) + if(context && passwordStoragePath){ + def password = GitPluginUtil.getFromKeyStorage(passwordStoragePath, context) if (password != null) { gitManager.setGitPassword(password) } } - // SSH Key from Key Storage (takes precedence if both are set) - if(context && configuration.getProperty(GitResourceModelFactory.GIT_KEY_STORAGE_PATH)){ - def sshKey = GitPluginUtil.getFromKeyStorage(configuration.getProperty(GitResourceModelFactory.GIT_KEY_STORAGE_PATH), context) + // SSH Key from Key Storage (takes precedence over filesystem path) + String keyStoragePath = configuration.getProperty(GitResourceModelFactory.GIT_KEY_STORAGE_PATH) + if(context && keyStoragePath){ + def sshKey = GitPluginUtil.getFromKeyStorage(keyStoragePath, context) if (sshKey != null) { gitManager.setSshPrivateKey(sshKey) + } else { + logger.warn("SSH key from Key Storage at path '{}' could not be retrieved", keyStoragePath) } + } else if (keyStoragePath && !context) { + logger.warn("SSH Key Storage path '{}' configured but Key Storage service is unavailable", keyStoragePath) } } diff --git a/src/main/groovy/com/rundeck/plugin/util/GitPluginUtil.groovy b/src/main/groovy/com/rundeck/plugin/util/GitPluginUtil.groovy index b4a5d6d..f5d7775 100644 --- a/src/main/groovy/com/rundeck/plugin/util/GitPluginUtil.groovy +++ b/src/main/groovy/com/rundeck/plugin/util/GitPluginUtil.groovy @@ -5,15 +5,17 @@ import com.dtolabs.rundeck.core.storage.ResourceMeta import com.dtolabs.rundeck.plugins.step.PluginStepContext import com.dtolabs.rundeck.core.execution.ExecutionContext import com.dtolabs.rundeck.core.storage.keys.KeyStorageTree -import com.dtolabs.rundeck.core.execution.ExecutionListener import groovy.transform.CompileStatic import java.nio.charset.StandardCharsets +import org.slf4j.Logger +import org.slf4j.LoggerFactory /** * Created by luistoledo on 12/18/17. */ @CompileStatic class GitPluginUtil { + private static final Logger logger = LoggerFactory.getLogger(GitPluginUtil.class) static Map getRenderOpt(String value, boolean secondary, boolean password = false, boolean storagePassword = false, boolean storageKey = false) { Map ret = new HashMap<>(); ret.put(StringRenderingConstants.GROUP_NAME,value); @@ -81,21 +83,17 @@ class GitPluginUtil { KeyStorageTree storageTree = (KeyStorageTree)context.getStorageTree() if (storageTree == null){ - ExecutionListener logger = context.getExecutionListener() - if (logger != null) { - logger.log(1, "storageTree is null. Cannot retrieve credential from Key Storage."); - } + logger.warn("storageTree is null. Cannot retrieve credential from Key Storage at path '{}'.", path) return null } try { ResourceMeta contents = storageTree.getResource(path).getContents(); - return readResourceMetaAsString(contents); + String result = readResourceMetaAsString(contents); + logger.debug("Successfully retrieved credential from Key Storage at path '{}' ({} chars)", path, result?.length()) + return result } catch (Exception e) { - ExecutionListener logger = context.getExecutionListener() - if (logger != null) { - logger.log(1, "Failed to retrieve credential from Key Storage at path '${path}': ${e.message}"); - } + logger.warn("Failed to retrieve credential from Key Storage at path '{}': {}", path, e.message) return null } }