mirror of
https://github.com/appleboy/drone-jenkins.git
synced 2026-06-16 14:49:16 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fbf3e5cd6 | |||
| 95a5eb125f | |||
| 984ca01afc |
+77
-13
@@ -9,6 +9,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -31,7 +32,14 @@ type (
|
|||||||
BaseURL string
|
BaseURL string
|
||||||
Token string // Remote trigger token
|
Token string // Remote trigger token
|
||||||
Client *http.Client
|
Client *http.Client
|
||||||
Debug bool // Enable debug mode to show detailed information
|
Debug bool // Enable debug mode to show detailed information
|
||||||
|
crumb *CrumbResponse // Cached CSRF crumb
|
||||||
|
}
|
||||||
|
|
||||||
|
// CrumbResponse represents Jenkins crumb issuer response for CSRF protection
|
||||||
|
CrumbResponse struct {
|
||||||
|
Crumb string `json:"crumb"`
|
||||||
|
CrumbRequestField string `json:"crumbRequestField"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueueItem represents a Jenkins queue item response
|
// QueueItem represents a Jenkins queue item response
|
||||||
@@ -144,14 +152,21 @@ func NewJenkins(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create HTTP client
|
// Create CookieJar for session management (required for CSRF crumb)
|
||||||
client := http.DefaultClient
|
jar, err := cookiejar.New(nil)
|
||||||
if tlsConfig != nil {
|
if err != nil {
|
||||||
client = &http.Client{
|
return nil, fmt.Errorf("failed to create cookie jar: %w", err)
|
||||||
Transport: &http.Transport{
|
}
|
||||||
TLSClientConfig: tlsConfig,
|
|
||||||
},
|
// Create HTTP Transport with optional TLS configuration
|
||||||
}
|
transport := &http.Transport{
|
||||||
|
TLSClientConfig: tlsConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP client with CookieJar and Transport
|
||||||
|
client := &http.Client{
|
||||||
|
Jar: jar,
|
||||||
|
Transport: transport,
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Jenkins{
|
return &Jenkins{
|
||||||
@@ -175,10 +190,49 @@ func (jenkins *Jenkins) buildURL(path string, params url.Values) (requestURL str
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (jenkins *Jenkins) sendRequest(req *http.Request) (*http.Response, error) {
|
// getCrumb fetches CSRF crumb from Jenkins
|
||||||
if jenkins.Auth != nil {
|
// Returns nil if CSRF protection is disabled on Jenkins
|
||||||
|
//
|
||||||
|
//nolint:unparam // Error return kept for future extensibility and API consistency
|
||||||
|
func (jenkins *Jenkins) getCrumb(ctx context.Context) (*CrumbResponse, error) {
|
||||||
|
// Return cached crumb if available
|
||||||
|
if jenkins.crumb != nil {
|
||||||
|
return jenkins.crumb, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
path := "/crumbIssuer/api/json"
|
||||||
|
var crumb CrumbResponse
|
||||||
|
err := jenkins.get(ctx, path, nil, &crumb)
|
||||||
|
if err != nil {
|
||||||
|
// CSRF protection might be disabled, log and continue
|
||||||
|
if jenkins.Debug {
|
||||||
|
log.Printf("crumb not available (CSRF may be disabled): %v", err)
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the crumb for subsequent requests
|
||||||
|
jenkins.crumb = &crumb
|
||||||
|
if jenkins.Debug {
|
||||||
|
log.Printf("obtained crumb: %s=%s", crumb.CrumbRequestField, crumb.Crumb)
|
||||||
|
}
|
||||||
|
|
||||||
|
return jenkins.crumb, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (jenkins *Jenkins) sendRequest(
|
||||||
|
req *http.Request,
|
||||||
|
crumb *CrumbResponse,
|
||||||
|
) (*http.Response, error) {
|
||||||
|
if jenkins.Auth != nil && jenkins.Auth.Username != "" && jenkins.Auth.Token != "" {
|
||||||
req.SetBasicAuth(jenkins.Auth.Username, jenkins.Auth.Token)
|
req.SetBasicAuth(jenkins.Auth.Username, jenkins.Auth.Token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add CSRF crumb header if available
|
||||||
|
if crumb != nil && crumb.CrumbRequestField != "" {
|
||||||
|
req.Header.Set(crumb.CrumbRequestField, crumb.Crumb)
|
||||||
|
}
|
||||||
|
|
||||||
return jenkins.Client.Do(req)
|
return jenkins.Client.Do(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +249,7 @@ func (jenkins *Jenkins) get(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := jenkins.sendRequest(req)
|
resp, err := jenkins.sendRequest(req, nil) // GET requests don't need crumb
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -223,6 +277,16 @@ func (jenkins *Jenkins) postAndGetLocation(
|
|||||||
path string,
|
path string,
|
||||||
params url.Values,
|
params url.Values,
|
||||||
) (int, error) {
|
) (int, error) {
|
||||||
|
// Fetch CSRF crumb before POST request (only if authenticated)
|
||||||
|
var crumb *CrumbResponse
|
||||||
|
if jenkins.Auth != nil && jenkins.Auth.Username != "" && jenkins.Auth.Token != "" {
|
||||||
|
var err error
|
||||||
|
crumb, err = jenkins.getCrumb(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to get crumb: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
requestURL := jenkins.buildURL(path, params)
|
requestURL := jenkins.buildURL(path, params)
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "POST", requestURL, nil)
|
req, err := http.NewRequestWithContext(ctx, "POST", requestURL, nil)
|
||||||
@@ -230,7 +294,7 @@ func (jenkins *Jenkins) postAndGetLocation(
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := jenkins.sendRequest(req)
|
resp, err := jenkins.sendRequest(req, crumb)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -751,6 +751,8 @@ func TestNewJenkinsWithCACert(t *testing.T) {
|
|||||||
)
|
)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, jenkins)
|
assert.NotNil(t, jenkins)
|
||||||
assert.Equal(t, http.DefaultClient, jenkins.Client)
|
assert.NotNil(t, jenkins.Client)
|
||||||
|
// Client should have CookieJar for CSRF session management
|
||||||
|
assert.NotNil(t, jenkins.Client.Jar)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,12 +88,15 @@ func (p Plugin) validateConfig() error {
|
|||||||
if p.BaseURL == "" {
|
if p.BaseURL == "" {
|
||||||
return errors.New("jenkins base URL is required")
|
return errors.New("jenkins base URL is required")
|
||||||
}
|
}
|
||||||
if p.Username == "" {
|
|
||||||
return errors.New("jenkins username is required")
|
// Validate authentication: either (user + token) or remote-token must be provided
|
||||||
}
|
hasUserAuth := p.Username != "" && p.Token != ""
|
||||||
if p.Token == "" {
|
hasRemoteToken := p.RemoteToken != ""
|
||||||
return errors.New("jenkins API token is required")
|
|
||||||
|
if !hasUserAuth && !hasRemoteToken {
|
||||||
|
return errors.New("authentication required")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,10 +116,13 @@ func (p Plugin) Exec(ctx context.Context) error {
|
|||||||
return errors.New("at least one Jenkins job name is required")
|
return errors.New("at least one Jenkins job name is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up authentication
|
// Set up authentication (only if username and token are provided)
|
||||||
auth := &Auth{
|
var auth *Auth
|
||||||
Username: p.Username,
|
if p.Username != "" && p.Token != "" {
|
||||||
Token: p.Token,
|
auth = &Auth{
|
||||||
|
Username: p.Username,
|
||||||
|
Token: p.Token,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Jenkins client
|
// Initialize Jenkins client
|
||||||
|
|||||||
+42
-9
@@ -32,24 +32,33 @@ func TestValidateConfig(t *testing.T) {
|
|||||||
errorMsg: "jenkins base URL is required",
|
errorMsg: "jenkins base URL is required",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing username and token",
|
name: "missing authentication",
|
||||||
plugin: Plugin{
|
plugin: Plugin{
|
||||||
BaseURL: "http://example.com",
|
BaseURL: "http://example.com",
|
||||||
},
|
},
|
||||||
wantError: true,
|
wantError: true,
|
||||||
errorMsg: "jenkins username is required",
|
errorMsg: "authentication required",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing token",
|
name: "missing token (only username)",
|
||||||
plugin: Plugin{
|
plugin: Plugin{
|
||||||
BaseURL: "http://example.com",
|
BaseURL: "http://example.com",
|
||||||
Username: "foo",
|
Username: "foo",
|
||||||
},
|
},
|
||||||
wantError: true,
|
wantError: true,
|
||||||
errorMsg: "jenkins API token is required",
|
errorMsg: "authentication required",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "all required config present",
|
name: "missing username (only token)",
|
||||||
|
plugin: Plugin{
|
||||||
|
BaseURL: "http://example.com",
|
||||||
|
Token: "bar",
|
||||||
|
},
|
||||||
|
wantError: true,
|
||||||
|
errorMsg: "authentication required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user and token auth",
|
||||||
plugin: Plugin{
|
plugin: Plugin{
|
||||||
BaseURL: "http://example.com",
|
BaseURL: "http://example.com",
|
||||||
Username: "foo",
|
Username: "foo",
|
||||||
@@ -57,6 +66,24 @@ func TestValidateConfig(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantError: false,
|
wantError: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "remote token auth",
|
||||||
|
plugin: Plugin{
|
||||||
|
BaseURL: "http://example.com",
|
||||||
|
RemoteToken: "remote-token-123",
|
||||||
|
},
|
||||||
|
wantError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both auth methods",
|
||||||
|
plugin: Plugin{
|
||||||
|
BaseURL: "http://example.com",
|
||||||
|
Username: "foo",
|
||||||
|
Token: "bar",
|
||||||
|
RemoteToken: "remote-token-123",
|
||||||
|
},
|
||||||
|
wantError: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -232,7 +259,7 @@ func TestExecMissingJenkinsUsername(t *testing.T) {
|
|||||||
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "configuration error")
|
assert.Contains(t, err.Error(), "configuration error")
|
||||||
assert.Contains(t, err.Error(), "jenkins username is required")
|
assert.Contains(t, err.Error(), "authentication required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestExecMissingJenkinsToken tests Exec with missing token
|
// TestExecMissingJenkinsToken tests Exec with missing token
|
||||||
@@ -246,7 +273,7 @@ func TestExecMissingJenkinsToken(t *testing.T) {
|
|||||||
|
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "configuration error")
|
assert.Contains(t, err.Error(), "configuration error")
|
||||||
assert.Contains(t, err.Error(), "jenkins API token is required")
|
assert.Contains(t, err.Error(), "authentication required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestExecMissingJenkinsJob tests Exec with missing or empty job list
|
// TestExecMissingJenkinsJob tests Exec with missing or empty job list
|
||||||
@@ -314,7 +341,10 @@ func TestExecTriggerMultipleJobs(t *testing.T) {
|
|||||||
// Create a mock Jenkins server
|
// Create a mock Jenkins server
|
||||||
jobsTriggered := 0
|
jobsTriggered := 0
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
jobsTriggered++
|
// Only count POST requests (job triggers), not GET requests (crumb)
|
||||||
|
if r.Method == "POST" {
|
||||||
|
jobsTriggered++
|
||||||
|
}
|
||||||
w.Header().
|
w.Header().
|
||||||
Set("Location", fmt.Sprintf("http://jenkins.example.com/queue/item/%d/", jobsTriggered))
|
Set("Location", fmt.Sprintf("http://jenkins.example.com/queue/item/%d/", jobsTriggered))
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
@@ -390,7 +420,10 @@ func TestExecWithJobsContainingWhitespace(t *testing.T) {
|
|||||||
// Create a mock Jenkins server
|
// Create a mock Jenkins server
|
||||||
jobsTriggered := 0
|
jobsTriggered := 0
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
jobsTriggered++
|
// Only count POST requests (job triggers), not GET requests (crumb)
|
||||||
|
if r.Method == "POST" {
|
||||||
|
jobsTriggered++
|
||||||
|
}
|
||||||
w.Header().
|
w.Header().
|
||||||
Set("Location", fmt.Sprintf("http://jenkins.example.com/queue/item/%d/", jobsTriggered))
|
Set("Location", fmt.Sprintf("http://jenkins.example.com/queue/item/%d/", jobsTriggered))
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
|||||||
Reference in New Issue
Block a user