Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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" }
Expand Down
62 changes: 53 additions & 9 deletions src/main/groovy/com/rundeck/plugin/GitManager.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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
/**
Expand All @@ -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> T withPluginClassLoader(Closure<T> 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
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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()
Expand Down Expand Up @@ -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()
}

Expand All @@ -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() {
Expand All @@ -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)
Expand Down
39 changes: 28 additions & 11 deletions src/main/groovy/com/rundeck/plugin/GitResourceModel.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ 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.
*/
@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;
Expand Down Expand Up @@ -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)
}
}

Expand Down
18 changes: 8 additions & 10 deletions src/main/groovy/com/rundeck/plugin/util/GitPluginUtil.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> getRenderOpt(String value, boolean secondary, boolean password = false, boolean storagePassword = false, boolean storageKey = false) {
Map<String, Object> ret = new HashMap<>();
ret.put(StringRenderingConstants.GROUP_NAME,value);
Expand Down Expand Up @@ -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
}
}
Expand Down
Loading
Loading