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 <appleboy.tw@gmail.com>

* test: update test CA certificate with new sample

- Replace the sample CA certificate used in tests with a new certificate

Signed-off-by: appleboy <appleboy.tw@gmail.com>

---------

Signed-off-by: appleboy <appleboy.tw@gmail.com>
This commit is contained in:
Bo-Yi Wu
2025-12-06 13:11:29 +08:00
committed by GitHub
parent f3a67c62a6
commit 02829360ad
5 changed files with 401 additions and 20 deletions
+61 -1
View File
@@ -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
+91 -8
View File
@@ -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) {
+236 -10
View File
@@ -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)
})
}
+8
View File
@@ -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,
+5 -1
View File
@@ -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)