Skip to content

Commit

Permalink
Merge pull request #1 from kentrikos/feature/credential-mover
Browse files Browse the repository at this point in the history
This enables migration of credentials between Jenkins instances.

Butler will get the XML config of any folder and will ingest
the encrypted secrets. The encrypted secrets are then decrypted
via a Groovy script executed on Jenkins master. This needs
most of the times admin privileges.

In a second step you can pipe in the decrypted credentials
and apply them to another folder or even instance.
Also a Groovy script is leveraged in order to achieve this.
  • Loading branch information
icereed authored Nov 27, 2018
2 parents e985806 + 0e17582 commit 6f1b1a7
Show file tree
Hide file tree
Showing 6 changed files with 406 additions and 6 deletions.
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,23 +64,32 @@ In order to always skip folders, you may set the environment variable `JENKINS_S
### Jobs Management

```
$ butler jobs export --server localhost:8080 --username admin --password admin --skip-folder
$ butler jobs export --server localhost:8080 --skip-folder
```

```
$ butler jobs import --server localhost:8080 --username admin --password admin
$ butler jobs import --server localhost:8080
```

### Plugins Management

```
$ butler plugins export --server localhost:8080 --username admin --password admin
$ butler plugins export --server localhost:8080
```

```
$ butler plugins import --server localhost:8080 --username admin --password admin
$ butler plugins import --server localhost:8080
```

### Credentials Management

```
$ butler credentials decrypt --server localhost:8080 --folder foo/bar > decryptedCredentials.json
```

```
$ cat decryptedCredentials.json | butler credentials apply --server localhost:8080 --folder bar/foo
```
## Tutorials

* [Butler CLI: Import/Export Jenkins Plugins & Jobs](http://www.blog.labouardy.com/butler-cli-import-export-jenkins-plugins-jobs/)
32 changes: 32 additions & 0 deletions credentials.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package main

import (
"encoding/json"
"fmt"
"log"
"os"
)

func DecryptFolderCredentials(url string, folderName string, username string, password string) error {
folder, _ := GetFolder(url, folderName, username, password)
credentials := folder.GetCredentials()
script := GetDecryptScriptForCredentials(credentials)
response := ExecuteGroovyScriptOnJenkins(script, url, username, password)
fmt.Println(response)
return nil
}

func ApplyFolderCredentials(url string, folderName string, username string, password string) error {
var credentials Credentials

err := json.NewDecoder(os.Stdin).Decode(&credentials)
if err != nil {
log.Fatal(err)
panic(err)
}
script := GetApplyScriptForCredentials(credentials, folderName)
response := ExecuteGroovyScriptOnJenkins(script, url, username, password)
fmt.Println(response)

return nil
}
82 changes: 81 additions & 1 deletion folder.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,47 @@
package main

import "strings"
import (
"encoding/xml"
"errors"
"fmt"
"io/ioutil"
"net/http"
"strings"
)

type JenkinsFolder struct {
Properties struct {
CredentialProperty struct {
DomainCredentials struct {
Class string `xml:"class,attr"`
Entry struct {
Credentials Credentials `xml:"java.util.concurrent.CopyOnWriteArrayList"`
} `xml:"entry"`
} `xml:"domainCredentialsMap"`
} `xml:"com.cloudbees.hudson.plugins.folder.properties.FolderCredentialsProvider_-FolderCredentialsProperty"`
} `xml:"properties"`
}

type Credentials struct {
UsernamePassword []UsernamePasswordCredential `xml:"com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl" json:"userpass"`
SecretFile []SecretFileCredential `xml:"org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl" json:"secretfile"`
}

type UsernamePasswordCredential struct {
Plugin string `xml:"plugin,attr" json:"plugin"`
ID string `xml:"id" json:"id"`
Description string `xml:"description" json:"description"`
Username string `xml:"username" json:"username"`
Password string `xml:"password" json:"password"`
}

type SecretFileCredential struct {
Plugin string `xml:"plugin,attr"`
ID string `xml:"id" json:"id"`
Description string `xml:"description" json:"description"`
FileName string `xml:"fileName" json:"fileName"`
SecretBytes string `xml:"secretBytes" json:"secretBytes"`
}

func GetFolderURL(url string, folderName string) string {
if folderName == "" {
Expand All @@ -15,3 +56,42 @@ func GetFolderURL(url string, folderName string) string {
path := strings.Replace(folderName, "/", "/job/", -1)
return url + path
}

func parseJenkinsFolder(xmlInput []byte) JenkinsFolder {
var folder JenkinsFolder
xmlInput = []byte(strings.Trim(string(xmlInput), "<?xml version='1.1' encoding='UTF-8'?>"))
xml.Unmarshal(xmlInput, &folder)
return folder
}

func (folder *JenkinsFolder) GetCredentials() Credentials {
return folder.Properties.CredentialProperty.DomainCredentials.Entry.Credentials
}

func GetFolder(url string, folderName string, username string, password string) (JenkinsFolder, error) {
url = fmt.Sprintf("%s/config.xml", GetFolderURL(url, folderName))

client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
req.SetBasicAuth(username, password)
if err != nil {
return JenkinsFolder{}, err
}

resp, err := client.Do(req)
if err != nil {
return JenkinsFolder{}, err
}
defer resp.Body.Close()

if resp.StatusCode == 401 {
return JenkinsFolder{}, errors.New("Unauthorized 401")
}

data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return JenkinsFolder{}, err
}

return parseJenkinsFolder(data), nil
}
64 changes: 63 additions & 1 deletion folder_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package main

import "testing"
import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestGetFolderURL(t *testing.T) {
type args struct {
Expand Down Expand Up @@ -69,3 +73,61 @@ func TestGetFolderURL(t *testing.T) {
})
}
}

func TestParseJenkinsFolder(t *testing.T) {
assert := assert.New(t)
xml := `<?xml version='1.1' encoding='UTF-8'?>
<com.cloudbees.hudson.plugins.folder.Folder plugin="[email protected]">
<actions/>
<displayName>Ansible</displayName>
<properties>
<com.cloudbees.hudson.plugins.folder.properties.FolderCredentialsProvider_-FolderCredentialsProperty>
<domainCredentialsMap class="hudson.util.CopyOnWriteMap$Hash">
<entry>
<com.cloudbees.plugins.credentials.domains.Domain plugin="[email protected]">
<specifications/>
</com.cloudbees.plugins.credentials.domains.Domain>
<java.util.concurrent.CopyOnWriteArrayList>
<com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl plugin="[email protected]">
<id>test</id>
<description>Test</description>
<username>Test</username>
<password>{AQAAABAAAAAQ7EzV5N/fXZEKM9HyG+1T66P67iqU+tptVCNuvNX1TM0=}</password>
</com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl>
<org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl plugin="[email protected]">
<id>deploy-key-file</id>
<description>blub</description>
<fileName>accessKeys.csv</fileName>
<secretBytes>{bEtRRJ+hCoQHgEAmcGhAOlKFx6J5tVuKmwdBVSgdq4zkktsLwG1zHO6swI3mQ5z9UhbgRRHDf2W8oSHlfmno8+KHWKWKyNmQUL5cv6/8n5JnmvsMGx+DT4KJL2XDVl33nuNbDpkcJEDGBWqb2hA47iRtW6h4mxlbNja5E12eUMs=}</secretBytes>
</org.jenkinsci.plugins.plaincredentials.impl.FileCredentialsImpl>
</java.util.concurrent.CopyOnWriteArrayList>
</entry>
</domainCredentialsMap>
</com.cloudbees.hudson.plugins.folder.properties.FolderCredentialsProvider_-FolderCredentialsProperty>
</properties>
<folderViews class="com.cloudbees.hudson.plugins.folder.views.DefaultFolderViewHolder">
<views>
<hudson.model.AllView>
<owner class="com.cloudbees.hudson.plugins.folder.Folder" reference="../../../.."/>
<name>all</name>
<filterExecutors>false</filterExecutors>
<filterQueue>false</filterQueue>
<properties class="hudson.model.View$PropertyList"/>
</hudson.model.AllView>
</views>
<primaryView>all</primaryView>
<tabBar class="hudson.views.DefaultViewsTabBar"/>
</folderViews>
<healthMetrics>
<com.cloudbees.hudson.plugins.folder.health.WorstChildHealthMetric>
<nonRecursive>false</nonRecursive>
</com.cloudbees.hudson.plugins.folder.health.WorstChildHealthMetric>
</healthMetrics>
<icon class="com.cloudbees.hudson.plugins.folder.icons.StockFolderIcon"/>
</com.cloudbees.hudson.plugins.folder.Folder>`

got := parseJenkinsFolder([]byte(xml))

assert.NotNil(got, "Should not be nil.")
assert.Equal(got.GetCredentials().UsernamePassword[0].ID, "test")
}
125 changes: 125 additions & 0 deletions groovy_scripts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package main

import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"strings"
)

func GetDecryptScriptForCredentials(credentials Credentials) string {
marshalledCredentials, _ := json.Marshal(credentials)
return strings.Replace(decryptScriptTemplate, "<<JSON HERE>>", string(marshalledCredentials), 1)
}

const decryptScriptTemplate = `import groovy.json.JsonSlurperClassic
import groovy.json.JsonOutput
def json = """<<JSON HERE>>"""
def data = new JsonSlurperClassic().parseText(json)
data.userpass.each {
it.password = hudson.util.Secret.fromString(it.password).getPlainText()
}
data.secretfile.each {
it.secretBytes = new String(com.cloudbees.plugins.credentials.SecretBytes.fromString(it.secretBytes).getPlainData(), "ASCII")
}
println JsonOutput.toJson(data)`

func GetApplyScriptForCredentials(credentials Credentials, folderPath string) string {
marshalledCredentials, _ := json.Marshal(credentials)
templated := strings.Replace(createOrUpdateCredentialsTemplate, "<<JSON HERE>>", string(marshalledCredentials), 1)
templated = strings.Replace(templated, "<<FOLDER HERE>>", folderPath, 1)
return templated
}

const createOrUpdateCredentialsTemplate = `import com.cloudbees.hudson.plugins.folder.properties.FolderCredentialsProvider.FolderCredentialsProperty
import com.cloudbees.hudson.plugins.folder.AbstractFolder
import com.cloudbees.hudson.plugins.folder.Folder
import jenkins.model.*
import com.cloudbees.plugins.credentials.*
import com.cloudbees.plugins.credentials.common.*
import com.cloudbees.plugins.credentials.domains.*
import com.cloudbees.plugins.credentials.impl.*
import org.jenkinsci.plugins.plaincredentials.*
import org.jenkinsci.plugins.plaincredentials.impl.*
import org.apache.commons.fileupload.FileItem
import groovy.json.JsonSlurperClassic
import groovy.json.JsonOutput
def createOrUpdateCredential(credentialStore, newCredential, existingCredentials) {
def existingCredential = existingCredentials.find{c -> c.getId() == newCredential.getId()}
if (existingCredential)
credentialStore.updateCredentials(Domain.global(), existingCredential, newCredential)
else
credentialStore.addCredentials(Domain.global(), newCredential)
}
def json = """<<JSON HERE>>"""
String folderPath = "<<FOLDER HERE>>"
def data = new JsonSlurperClassic().parseText(json)
Jenkins.instance.getAllItems(Folder.class)
.findAll{it.fullName.equals(folderPath)}
.each{
AbstractFolder<?> folderAbs = AbstractFolder.class.cast(it)
println "We're at ${folderAbs.fullName}"
FolderCredentialsProperty property = folderAbs.getProperties().get(FolderCredentialsProperty.class)
if(property == null){
property = new FolderCredentialsProperty()
folderAbs.addProperty(property)
}
def store = property.getStore()
def existingCredentials = property.getCredentials()
data.userpass.each {
Credentials c = new UsernamePasswordCredentialsImpl(CredentialsScope.GLOBAL, it.id, it.description, it.username, it.password)
createOrUpdateCredential(store, c, existingCredentials)
}
data.secretfile.each {
def rawSecretFile = it
fileItem = [ getName: { return rawSecretFile.fileName}, get: { return rawSecretFile.secretBytes.getBytes() } ] as FileItem
secretFile = new FileCredentialsImpl(
CredentialsScope.GLOBAL,
rawSecretFile.id,
rawSecretFile.description,
fileItem, // Don't use FileItem
null,
"")
createOrUpdateCredential(store, secretFile, existingCredentials)
}
println existingCredentials.toString()
}`

func ExecuteGroovyScriptOnJenkins(script string, rawUrl string, username string, password string) string {
apiURL := fmt.Sprintf("%s/scriptText", rawUrl)
data := url.Values{}
data.Set("script", script)
body := strings.NewReader(data.Encode())
req, err := http.NewRequest("POST", apiURL, body)
if err != nil {
panic(err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

crumb, err := GetCrumb(rawUrl, username, password)
if err != nil {
fmt.Errorf("No crumb issueing possible: %v", err)
} else {
req.Header.Set(crumb[0], crumb[1])
}

req.SetBasicAuth(username, password)
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
responseBody, _ := ioutil.ReadAll(resp.Body)
return string(responseBody)
}
Loading

0 comments on commit 6f1b1a7

Please sign in to comment.