From 02829360ad2e8be43d721777156539d794d5d4a0 Mon Sep 17 00:00:00 2001 From: Bo-Yi Wu Date: Sat, 6 Dec 2025 13:11:29 +0800 Subject: [PATCH] feat: add support for custom CA certificates for SSL/TLS connections (#42) * feat: add support for custom CA certificates for SSL/TLS connections - Add support for custom CA certificates (via PEM content, file path, or HTTP/HTTPS URL) for SSL/TLS connections. - Document the new CA certificate option and usage examples for CLI, Docker, and Drone CI in the README. - Update Jenkins client initialization to load and validate a custom CA certificate if provided, using a priority where insecure mode overrides custom CA. - Introduce comprehensive tests for CA certificate loading and Jenkins client initialization with different CA certificate sources and error scenarios. - Register the new ca-cert command-line flag and propagate its value through configuration and debug output. - Ensure that error handling for certificate loading fully propagates failures. Signed-off-by: appleboy * test: update test CA certificate with new sample - Replace the sample CA certificate used in tests with a new certificate Signed-off-by: appleboy --------- Signed-off-by: appleboy --- README.md | 62 +++++++++++- jenkins.go | 99 +++++++++++++++++-- jenkins_test.go | 246 ++++++++++++++++++++++++++++++++++++++++++++++-- main.go | 8 ++ plugin.go | 6 +- 5 files changed, 401 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index b7f0de7..5bbb0dd 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ A [Drone](https://github.com/drone/drone) plugin for triggering [Jenkins](https: - Multiple authentication methods (API token or remote trigger token) - Wait for job completion with configurable polling and timeout - Debug mode with detailed parameter information and secure token masking -- SSL/TLS support with optional insecure mode +- SSL/TLS support with custom CA certificates (PEM content, file path, or URL) - Cross-platform support (Linux, macOS, Windows) - Available as binary, Docker image, or Drone plugin @@ -127,6 +127,7 @@ Alternatively, you can use a remote trigger token configured in your Jenkins job | Job | `--job`, `-j` | `PLUGIN_JOB`, `JENKINS_JOB` | Yes | Jenkins job name(s) - can specify multiple | | Parameters | `--parameters`, `-p` | `PLUGIN_PARAMETERS`, `JENKINS_PARAMETERS` | No | Build parameters in multi-line `key=value` format (one per line) | | Insecure | `--insecure` | `PLUGIN_INSECURE`, `JENKINS_INSECURE` | No | Allow insecure SSL connections (default: false) | +| CA Cert | `--ca-cert` | `PLUGIN_CA_CERT`, `JENKINS_CA_CERT` | No | Custom CA certificate (PEM content, file path, or HTTP URL) | | Wait | `--wait` | `PLUGIN_WAIT`, `JENKINS_WAIT` | No | Wait for job completion (default: false) | | Poll Interval | `--poll-interval` | `PLUGIN_POLL_INTERVAL`, `JENKINS_POLL_INTERVAL` | No | Interval between status checks (default: 10s) | | Timeout | `--timeout` | `PLUGIN_TIMEOUT`, `JENKINS_TIMEOUT` | No | Maximum time to wait for job completion (default: 30m) | @@ -230,6 +231,26 @@ drone-jenkins \ --debug ``` +**With custom CA certificate:** + +```bash +# Using a file path +drone-jenkins \ + --host https://jenkins.example.com/ \ + --user appleboy \ + --token XXXXXXXX \ + --job my-jenkins-job \ + --ca-cert /path/to/ca.pem + +# Using a URL +drone-jenkins \ + --host https://jenkins.example.com/ \ + --user appleboy \ + --token XXXXXXXX \ + --job my-jenkins-job \ + --ca-cert https://example.com/ca-bundle.crt +``` + ### Docker **Single job:** @@ -292,6 +313,29 @@ docker run --rm \ ghcr.io/appleboy/drone-jenkins ``` +**With custom CA certificate:** + +```bash +# Using a mounted certificate file +docker run --rm \ + -v /path/to/ca.pem:/ca.pem:ro \ + -e JENKINS_URL=https://jenkins.example.com/ \ + -e JENKINS_USER=appleboy \ + -e JENKINS_TOKEN=xxxxxxx \ + -e JENKINS_JOB=my-jenkins-job \ + -e JENKINS_CA_CERT=/ca.pem \ + ghcr.io/appleboy/drone-jenkins + +# Using a URL +docker run --rm \ + -e JENKINS_URL=https://jenkins.example.com/ \ + -e JENKINS_USER=appleboy \ + -e JENKINS_TOKEN=xxxxxxx \ + -e JENKINS_JOB=my-jenkins-job \ + -e JENKINS_CA_CERT=https://example.com/ca-bundle.crt \ + ghcr.io/appleboy/drone-jenkins +``` + ### Drone CI Add the plugin to your `.drone.yml`: @@ -377,6 +421,22 @@ steps: debug: true ``` +**With custom CA certificate:** + +```yaml +steps: + - name: trigger-jenkins + image: ghcr.io/appleboy/drone-jenkins + settings: + url: https://jenkins.example.com/ + user: appleboy + token: + from_secret: jenkins_token + job: my-jenkins-job + ca_cert: + from_secret: jenkins_ca_cert +``` + For more detailed examples and advanced configurations, see [DOCS.md](DOCS.md). ## Development diff --git a/jenkins.go b/jenkins.go index d921f39..4a0e865 100644 --- a/jenkins.go +++ b/jenkins.go @@ -3,12 +3,14 @@ package main import ( "context" "crypto/tls" + "crypto/x509" "encoding/json" "fmt" "io" "log" "net/http" "net/url" + "os" "strings" "time" @@ -56,27 +58,108 @@ type ( } ) -// NewJenkins is initial Jenkins object -func NewJenkins(auth *Auth, url string, token string, insecure bool, debug bool) *Jenkins { - url = strings.TrimRight(url, "/") +// loadCACert loads a CA certificate from various sources: +// - PEM content (if it starts with "-----BEGIN") +// - File path (if the file exists) +// - HTTP/HTTPS URL (if it starts with "http://" or "https://") +func loadCACert(caCert string) ([]byte, error) { + if caCert == "" { + return nil, nil + } - client := http.DefaultClient + // Check if it's PEM content (starts with BEGIN marker) + if strings.HasPrefix(strings.TrimSpace(caCert), "-----BEGIN") { + return []byte(caCert), nil + } + + // Check if it's an HTTP/HTTPS URL + if strings.HasPrefix(caCert, "http://") || strings.HasPrefix(caCert, "https://") { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, caCert, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request for CA certificate URL: %w", err) + } + resp, err := http.DefaultClient.Do(req) // #nosec G107 -- URL is user-provided configuration + if err != nil { + return nil, fmt.Errorf("failed to fetch CA certificate from URL: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch CA certificate: HTTP %d", resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read CA certificate from URL: %w", err) + } + return data, nil + } + + // Otherwise, treat it as a file path + data, err := os.ReadFile(caCert) + if err != nil { + return nil, fmt.Errorf("failed to read CA certificate file: %w", err) + } + return data, nil +} + +// NewJenkins is initial Jenkins object +func NewJenkins( + auth *Auth, + baseURL string, + token string, + insecure bool, + caCert string, + debug bool, +) (*Jenkins, error) { + baseURL = strings.TrimRight(baseURL, "/") + + // Load CA certificate if provided + caCertData, err := loadCACert(caCert) + if err != nil { + return nil, fmt.Errorf("failed to load CA certificate: %w", err) + } + + // Build TLS configuration + var tlsConfig *tls.Config if insecure { + // #nosec G402 -- InsecureSkipVerify is intentionally configurable by user + tlsConfig = &tls.Config{InsecureSkipVerify: true} + } else if caCertData != nil { + // Create certificate pool with custom CA + certPool, err := x509.SystemCertPool() + if err != nil { + // Fall back to empty pool if system pool unavailable + certPool = x509.NewCertPool() + } + + if !certPool.AppendCertsFromPEM(caCertData) { + return nil, fmt.Errorf("failed to parse CA certificate") + } + + tlsConfig = &tls.Config{ + RootCAs: certPool, + MinVersion: tls.VersionTLS12, + } + } + + // Create HTTP client + client := http.DefaultClient + if tlsConfig != nil { client = &http.Client{ Transport: &http.Transport{ - // #nosec G402 -- InsecureSkipVerify is intentionally configurable by user - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + TLSClientConfig: tlsConfig, }, } } return &Jenkins{ Auth: auth, - BaseURL: url, + BaseURL: baseURL, Token: token, Client: client, Debug: debug, - } + }, nil } func (jenkins *Jenkins) buildURL(path string, params url.Values) (requestURL string) { diff --git a/jenkins_test.go b/jenkins_test.go index 2c01933..7c62ca5 100644 --- a/jenkins_test.go +++ b/jenkins_test.go @@ -4,6 +4,8 @@ import ( "net/http" "net/http/httptest" "net/url" + "os" + "path/filepath" "sync/atomic" "testing" "time" @@ -16,7 +18,8 @@ func TestParseJobPath(t *testing.T) { Username: "appleboy", Token: "1234", } - jenkins := NewJenkins(auth, "http://example.com", "", false, false) + jenkins, err := NewJenkins(auth, "http://example.com", "", false, "", false) + assert.NoError(t, err) assert.Equal(t, "/job/foo", jenkins.parseJobPath("/foo/")) assert.Equal(t, "/job/foo", jenkins.parseJobPath("foo/")) @@ -29,7 +32,8 @@ func TestUnSupportProtocol(t *testing.T) { Username: "foo", Token: "bar", } - jenkins := NewJenkins(auth, "example.com", "", false, false) + jenkins, err := NewJenkins(auth, "example.com", "", false, "", false) + assert.NoError(t, err) queueID, err := jenkins.trigger("drone-jenkins", nil) assert.NotNil(t, err) @@ -50,7 +54,8 @@ func TestTriggerBuild(t *testing.T) { Username: "foo", Token: "bar", } - jenkins := NewJenkins(auth, server.URL, "remote-token", false, false) + jenkins, err := NewJenkins(auth, server.URL, "remote-token", false, "", false) + assert.NoError(t, err) params := url.Values{"param": []string{"value"}} queueID, err := jenkins.trigger("drone-jenkins", params) @@ -110,7 +115,8 @@ func TestPostAndGetLocation(t *testing.T) { Username: "test", Token: "test", } - jenkins := NewJenkins(auth, server.URL, "", false, false) + jenkins, err := NewJenkins(auth, server.URL, "", false, "", false) + assert.NoError(t, err) queueID, err := jenkins.postAndGetLocation("/test", nil) @@ -186,7 +192,8 @@ func TestGetQueueItem(t *testing.T) { Username: "test", Token: "test", } - jenkins := NewJenkins(auth, server.URL, "", false, false) + jenkins, err := NewJenkins(auth, server.URL, "", false, "", false) + assert.NoError(t, err) queueItem, err := jenkins.getQueueItem(tt.queueID) @@ -274,7 +281,8 @@ func TestGetBuildInfo(t *testing.T) { Username: "test", Token: "test", } - jenkins := NewJenkins(auth, server.URL, "", false, false) + jenkins, err := NewJenkins(auth, server.URL, "", false, "", false) + assert.NoError(t, err) buildInfo, err := jenkins.getBuildInfo(tt.jobName, tt.buildNumber) @@ -332,7 +340,8 @@ func TestWaitForCompletion(t *testing.T) { Username: "test", Token: "test", } - jenkins := NewJenkins(auth, server.URL, "", false, false) + jenkins, err := NewJenkins(auth, server.URL, "", false, "", false) + assert.NoError(t, err) buildInfo, err := jenkins.waitForCompletion( "test-job", @@ -364,7 +373,8 @@ func TestWaitForCompletion(t *testing.T) { Username: "test", Token: "test", } - jenkins := NewJenkins(auth, server.URL, "", false, false) + jenkins, err := NewJenkins(auth, server.URL, "", false, "", false) + assert.NoError(t, err) buildInfo, err := jenkins.waitForCompletion( "test-job", @@ -404,7 +414,8 @@ func TestWaitForCompletion(t *testing.T) { Username: "test", Token: "test", } - jenkins := NewJenkins(auth, server.URL, "", false, false) + jenkins, err := NewJenkins(auth, server.URL, "", false, "", false) + assert.NoError(t, err) buildInfo, err := jenkins.waitForCompletion( "test-job", @@ -449,7 +460,8 @@ func TestWaitForCompletion(t *testing.T) { Username: "test", Token: "test", } - jenkins := NewJenkins(auth, server.URL, "", false, false) + jenkins, err := NewJenkins(auth, server.URL, "", false, "", false) + assert.NoError(t, err) buildInfo, err := jenkins.waitForCompletion( "test-job", @@ -465,3 +477,217 @@ func TestWaitForCompletion(t *testing.T) { assert.Equal(t, "FAILURE", buildInfo.Result) }) } + +// Sample CA certificate for testing (self-signed, not for production use) +const testCACert = `-----BEGIN CERTIFICATE----- +MIIDAzCCAeugAwIBAgIUGYBGBr+t20UAWJorEPULxzGIXUEwDQYJKoZIhvcNAQEL +BQAwETEPMA0GA1UEAwwGdGVzdGNhMB4XDTI1MTIwNjA1MDgzMloXDTM1MTIwNDA1 +MDgzMlowETEPMA0GA1UEAwwGdGVzdGNhMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAq4bwnABqFenRVUoHLKhPiJXkh6TBFUaCWiEpKYNPywptBJNdyWNf +ouDxJ8gvQOMCkp3trnAHFcT6W5s8QLM1Hf/70QZI9GU/BtYm0KijU8aM+GJawNto +sK103TeCd0tVenDkxfamBGYnh3L5jtk0V/jeIsAIfFoe9Citu3MttRfxnSmZ4w2K +qlS14vKhFlO4WrXAh9j4PaVE5DL7jya/UKe6VVQIONCwUipRN6nU3UXhR7akVSmF +/bYkFsfdcErXJHjDpg+0xOsa5LJhzRkx5Uoqtviq2oRVVYhZc0eTwjq/407ocJ25 +6WmerfKrtFDpzOZPa4XPVX9Am4vWugtrwQIDAQABo1MwUTAdBgNVHQ4EFgQUh7kL +LqmsvQP3TI6eiLVK7Gs7A00wHwYDVR0jBBgwFoAUh7kLLqmsvQP3TI6eiLVK7Gs7 +A00wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEApLOdWacya+Zi +w0Fd3UfSveuRsayAkMkZ4p0L9XKlADzwKtSF1Ykn6wiEiYfXd2TvffsR2XglOXFc +181IpBhP5u2mzK6pRvH9mqTs3w8JTcXMFmg8AKE2Vg5k22tBM2OUJJgKXkiACuHS +pZeOOvJcnjGunbTRwqais0TLYnkOcFsbgrSBKv82HiVootH/iKZahf1ViFMOURTh +MqjwIous7Y53Rq4RmfycIjNwODlDW0i5atKe8incDBiIYKw6sH8WN+nuhnHC/vJ5 +5ZQvGCUsGOvma5ojWAiLs8wu4dODuF5ZNID3t+M36PQs7JDaQNN+AkZROOTSMqa/ +ud3vS1A5+g== +-----END CERTIFICATE-----` + +func TestLoadCACert(t *testing.T) { + t.Run("empty string returns nil", func(t *testing.T) { + data, err := loadCACert("") + assert.NoError(t, err) + assert.Nil(t, data) + }) + + t.Run("PEM content directly", func(t *testing.T) { + data, err := loadCACert(testCACert) + assert.NoError(t, err) + assert.NotNil(t, data) + assert.Contains(t, string(data), "BEGIN CERTIFICATE") + }) + + t.Run("PEM content with leading whitespace", func(t *testing.T) { + data, err := loadCACert(" \n" + testCACert) + assert.NoError(t, err) + assert.NotNil(t, data) + assert.Contains(t, string(data), "BEGIN CERTIFICATE") + }) + + t.Run("file path", func(t *testing.T) { + // Create a temporary file with the certificate + tmpDir := t.TempDir() + certFile := filepath.Join(tmpDir, "ca.pem") + err := os.WriteFile(certFile, []byte(testCACert), 0o600) + assert.NoError(t, err) + + data, err := loadCACert(certFile) + assert.NoError(t, err) + assert.NotNil(t, data) + assert.Contains(t, string(data), "BEGIN CERTIFICATE") + }) + + t.Run("file not found", func(t *testing.T) { + data, err := loadCACert("/nonexistent/path/ca.pem") + assert.Error(t, err) + assert.Nil(t, data) + assert.Contains(t, err.Error(), "failed to read CA certificate file") + }) + + t.Run("HTTP URL", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(testCACert)) + })) + defer server.Close() + + data, err := loadCACert(server.URL) + assert.NoError(t, err) + assert.NotNil(t, data) + assert.Contains(t, string(data), "BEGIN CERTIFICATE") + }) + + t.Run("HTTPS URL", func(t *testing.T) { + server := httptest.NewTLSServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(testCACert)) + }), + ) + defer server.Close() + + // Note: This test uses the test server's self-signed cert + // In real scenarios, the URL would be to a trusted source + // We skip HTTPS verification for this test + data, err := loadCACert(server.URL) + // This may fail due to certificate verification, which is expected + if err != nil { + assert.Contains(t, err.Error(), "certificate") + } else { + assert.NotNil(t, data) + } + }) + + t.Run("HTTP URL returns error status", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + data, err := loadCACert(server.URL) + assert.Error(t, err) + assert.Nil(t, data) + assert.Contains(t, err.Error(), "HTTP 404") + }) + + t.Run("HTTP URL unreachable", func(t *testing.T) { + data, err := loadCACert("http://localhost:59999/nonexistent") + assert.Error(t, err) + assert.Nil(t, data) + assert.Contains(t, err.Error(), "failed to fetch CA certificate from URL") + }) +} + +func TestNewJenkinsWithCACert(t *testing.T) { + t.Run("with valid CA certificate", func(t *testing.T) { + auth := &Auth{ + Username: "test", + Token: "test", + } + jenkins, err := NewJenkins(auth, "https://example.com", "", false, testCACert, false) + assert.NoError(t, err) + assert.NotNil(t, jenkins) + assert.NotNil(t, jenkins.Client) + }) + + t.Run("with CA certificate from file", func(t *testing.T) { + tmpDir := t.TempDir() + certFile := filepath.Join(tmpDir, "ca.pem") + err := os.WriteFile(certFile, []byte(testCACert), 0o600) + assert.NoError(t, err) + + auth := &Auth{ + Username: "test", + Token: "test", + } + jenkins, err := NewJenkins(auth, "https://example.com", "", false, certFile, false) + assert.NoError(t, err) + assert.NotNil(t, jenkins) + }) + + t.Run("with invalid CA certificate content", func(t *testing.T) { + auth := &Auth{ + Username: "test", + Token: "test", + } + jenkins, err := NewJenkins( + auth, + "https://example.com", + "", + false, + "invalid-cert-data", + false, + ) + assert.Error(t, err) + assert.Nil(t, jenkins) + assert.Contains(t, err.Error(), "failed to read CA certificate file") + }) + + t.Run("with invalid PEM format", func(t *testing.T) { + auth := &Auth{ + Username: "test", + Token: "test", + } + invalidPEM := "-----BEGIN CERTIFICATE-----\ninvalid-base64-data\n-----END CERTIFICATE-----" + jenkins, err := NewJenkins(auth, "https://example.com", "", false, invalidPEM, false) + assert.Error(t, err) + assert.Nil(t, jenkins) + assert.Contains(t, err.Error(), "failed to parse CA certificate") + }) + + t.Run("with nonexistent file path", func(t *testing.T) { + auth := &Auth{ + Username: "test", + Token: "test", + } + jenkins, err := NewJenkins( + auth, + "https://example.com", + "", + false, + "/nonexistent/ca.pem", + false, + ) + assert.Error(t, err) + assert.Nil(t, jenkins) + assert.Contains(t, err.Error(), "failed to load CA certificate") + }) + + t.Run("insecure flag takes precedence over CA cert", func(t *testing.T) { + auth := &Auth{ + Username: "test", + Token: "test", + } + // When insecure is true, CA cert should be ignored + jenkins, err := NewJenkins(auth, "https://example.com", "", true, testCACert, false) + assert.NoError(t, err) + assert.NotNil(t, jenkins) + }) + + t.Run("without CA certificate uses default client", func(t *testing.T) { + auth := &Auth{ + Username: "test", + Token: "test", + } + jenkins, err := NewJenkins(auth, "https://example.com", "", false, "", false) + assert.NoError(t, err) + assert.NotNil(t, jenkins) + assert.Equal(t, http.DefaultClient, jenkins.Client) + }) +} diff --git a/main.go b/main.go index 4a5f56e..48ad57f 100644 --- a/main.go +++ b/main.go @@ -92,6 +92,11 @@ func main() { Usage: "allow insecure server connections when using SSL", EnvVars: []string{"PLUGIN_INSECURE", "JENKINS_INSECURE", "INPUT_INSECURE"}, }, + &cli.StringFlag{ + Name: "ca-cert", + Usage: "custom CA certificate (PEM content, file path, or HTTP URL)", + EnvVars: []string{"PLUGIN_CA_CERT", "JENKINS_CA_CERT", "INPUT_CA_CERT"}, + }, &cli.StringFlag{ Name: "parameters", Aliases: []string{"p"}, @@ -184,6 +189,7 @@ func run(c *cli.Context) error { RemoteToken: c.String("remote-token"), Job: c.StringSlice("job"), Insecure: c.Bool("insecure"), + CACert: c.String("ca-cert"), Parameters: c.String("parameters"), Wait: c.Bool("wait"), PollInterval: c.Duration("poll-interval"), @@ -203,6 +209,7 @@ func run(c *cli.Context) error { RemoteToken string Job []string Insecure bool + CACert string Parameters string Wait bool PollInterval time.Duration @@ -215,6 +222,7 @@ func run(c *cli.Context) error { RemoteToken: maskToken(plugin.RemoteToken), Job: plugin.Job, Insecure: plugin.Insecure, + CACert: plugin.CACert, Parameters: plugin.Parameters, Wait: plugin.Wait, PollInterval: plugin.PollInterval, diff --git a/plugin.go b/plugin.go index 5673b0b..ddb7e08 100644 --- a/plugin.go +++ b/plugin.go @@ -19,6 +19,7 @@ type ( RemoteToken string // Optional remote trigger token for additional security Job []string // List of Jenkins job names to trigger Insecure bool // Whether to skip TLS certificate verification + CACert string // Custom CA certificate (PEM content, file path, or HTTP URL) Parameters string // Job parameters in key=value format (one per line) Wait bool // Whether to wait for job completion PollInterval time.Duration // Interval between status checks (default: 10s) @@ -117,7 +118,10 @@ func (p Plugin) Exec() error { } // Initialize Jenkins client - jenkins := NewJenkins(auth, p.BaseURL, p.RemoteToken, p.Insecure, p.Debug) + jenkins, err := NewJenkins(auth, p.BaseURL, p.RemoteToken, p.Insecure, p.CACert, p.Debug) + if err != nil { + return fmt.Errorf("failed to initialize Jenkins client: %w", err) + } // Parse job parameters params := parseParameters(p.Parameters)