diff --git a/completion/fish/task.fish b/completion/fish/task.fish index 3b7683fe02..725b3fbc7b 100644 --- a/completion/fish/task.fish +++ b/completion/fish/task.fish @@ -102,9 +102,12 @@ complete -c $GO_TASK_PROGNAME -s y -l yes -d 'assume yes t complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled GENTLE_FORCE" -l force-all -d 'force execution of task and all dependencies' # RemoteTaskfiles experiment - Options -complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l offline -d 'use only local or cached Taskfiles' -complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l timeout -d 'timeout for remote Taskfile downloads' -complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l expiry -d 'cache expiry duration' +complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l offline -d 'use only local or cached Taskfiles' +complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l timeout -d 'timeout for remote Taskfile downloads' +complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l expiry -d 'cache expiry duration' +complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cacert -d 'custom CA certificate for TLS' -r +complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cert -d 'client certificate for mTLS' -r +complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cert-key -d 'client certificate private key' -r # RemoteTaskfiles experiment - Operations complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l download -d 'download remote Taskfile' diff --git a/completion/ps/task.ps1 b/completion/ps/task.ps1 index 156faa443e..310a3b9c57 100644 --- a/completion/ps/task.ps1 +++ b/completion/ps/task.ps1 @@ -72,6 +72,9 @@ Register-ArgumentCompleter -CommandName task -ScriptBlock { $completions += [CompletionResult]::new('--offline', '--offline', [CompletionResultType]::ParameterName, 'use cached Taskfiles') $completions += [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'download timeout') $completions += [CompletionResult]::new('--expiry', '--expiry', [CompletionResultType]::ParameterName, 'cache expiry') + $completions += [CompletionResult]::new('--cacert', '--cacert', [CompletionResultType]::ParameterName, 'custom CA certificate') + $completions += [CompletionResult]::new('--cert', '--cert', [CompletionResultType]::ParameterName, 'client certificate') + $completions += [CompletionResult]::new('--cert-key', '--cert-key', [CompletionResultType]::ParameterName, 'client private key') # Operations $completions += [CompletionResult]::new('--download', '--download', [CompletionResultType]::ParameterName, 'download remote Taskfile') $completions += [CompletionResult]::new('--clear-cache', '--clear-cache', [CompletionResultType]::ParameterName, 'clear cache') diff --git a/completion/zsh/_task b/completion/zsh/_task index 08670531a9..41db8157d5 100755 --- a/completion/zsh/_task +++ b/completion/zsh/_task @@ -86,6 +86,9 @@ _task() { '(--offline)--offline[use only local or cached Taskfiles]' '(--timeout)--timeout[timeout for remote Taskfile downloads]:duration: ' '(--expiry)--expiry[cache expiry duration]:duration: ' + '(--cacert)--cacert[custom CA certificate for TLS]:file:_files' + '(--cert)--cert[client certificate for mTLS]:file:_files' + '(--cert-key)--cert-key[client certificate private key]:file:_files' ) fi diff --git a/executor.go b/executor.go index 6ecf910a5b..05786adc32 100644 --- a/executor.go +++ b/executor.go @@ -36,6 +36,9 @@ type ( Offline bool Timeout time.Duration CacheExpiryDuration time.Duration + CACert string + Cert string + CertKey string Watch bool Verbose bool Silent bool @@ -253,6 +256,45 @@ func (o *cacheExpiryDurationOption) ApplyToExecutor(r *Executor) { r.CacheExpiryDuration = o.duration } +// WithCACert sets the path to a custom CA certificate for TLS connections. +func WithCACert(caCert string) ExecutorOption { + return &caCertOption{caCert: caCert} +} + +type caCertOption struct { + caCert string +} + +func (o *caCertOption) ApplyToExecutor(e *Executor) { + e.CACert = o.caCert +} + +// WithCert sets the path to a client certificate for TLS connections. +func WithCert(cert string) ExecutorOption { + return &certOption{cert: cert} +} + +type certOption struct { + cert string +} + +func (o *certOption) ApplyToExecutor(e *Executor) { + e.Cert = o.cert +} + +// WithCertKey sets the path to a client certificate key for TLS connections. +func WithCertKey(certKey string) ExecutorOption { + return &certKeyOption{certKey: certKey} +} + +type certKeyOption struct { + certKey string +} + +func (o *certKeyOption) ApplyToExecutor(e *Executor) { + e.CertKey = o.certKey +} + // WithWatch tells the [Executor] to keep running in the background and watch // for changes to the fingerprint of the tasks that are run. When changes are // detected, a new task run is triggered. diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 5787e17a6d..e1251631cf 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -76,6 +76,9 @@ var ( ClearCache bool Timeout time.Duration CacheExpiryDuration time.Duration + CACert string + Cert string + CertKey string ) func init() { @@ -155,6 +158,9 @@ func init() { pflag.DurationVar(&Timeout, "timeout", getConfig(config, func() *time.Duration { return config.Remote.Timeout }, time.Second*10), "Timeout for downloading remote Taskfiles.") pflag.BoolVar(&ClearCache, "clear-cache", false, "Clear the remote cache.") pflag.DurationVar(&CacheExpiryDuration, "expiry", getConfig(config, func() *time.Duration { return config.Remote.CacheExpiry }, 0), "Expiry duration for cached remote Taskfiles.") + pflag.StringVar(&CACert, "cacert", getConfig(config, func() *string { return config.Remote.CACert }, ""), "Path to a custom CA certificate for HTTPS connections.") + pflag.StringVar(&Cert, "cert", getConfig(config, func() *string { return config.Remote.Cert }, ""), "Path to a client certificate for HTTPS connections.") + pflag.StringVar(&CertKey, "cert-key", getConfig(config, func() *string { return config.Remote.CertKey }, ""), "Path to a client certificate key for HTTPS connections.") } pflag.Parse() } @@ -200,6 +206,11 @@ func Validate() error { return errors.New("task: --nested only applies to --json with --list or --list-all") } + // Validate certificate flags + if (Cert != "" && CertKey == "") || (Cert == "" && CertKey != "") { + return errors.New("task: --cert and --cert-key must be provided together") + } + return nil } @@ -240,6 +251,9 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { task.WithOffline(Offline), task.WithTimeout(Timeout), task.WithCacheExpiryDuration(CacheExpiryDuration), + task.WithCACert(CACert), + task.WithCert(Cert), + task.WithCertKey(CertKey), task.WithWatch(Watch), task.WithVerbose(Verbose), task.WithSilent(Silent), diff --git a/setup.go b/setup.go index 6234ceefa0..837fb9aaf9 100644 --- a/setup.go +++ b/setup.go @@ -56,7 +56,11 @@ func (e *Executor) Setup() error { } func (e *Executor) getRootNode() (taskfile.Node, error) { - node, err := taskfile.NewRootNode(e.Entrypoint, e.Dir, e.Insecure, e.Timeout) + node, err := taskfile.NewRootNode(e.Entrypoint, e.Dir, e.Insecure, e.Timeout, + taskfile.WithCACert(e.CACert), + taskfile.WithCert(e.Cert), + taskfile.WithCertKey(e.CertKey), + ) if os.IsNotExist(err) { return nil, errors.TaskfileNotFoundError{ URI: fsext.DefaultDir(e.Entrypoint, e.Dir), @@ -86,6 +90,9 @@ func (e *Executor) readTaskfile(node taskfile.Node) error { taskfile.WithOffline(e.Offline), taskfile.WithTempDir(e.TempDir.Remote), taskfile.WithCacheExpiryDuration(e.CacheExpiryDuration), + taskfile.WithReaderCACert(e.CACert), + taskfile.WithReaderCert(e.Cert), + taskfile.WithReaderCertKey(e.CertKey), taskfile.WithDebugFunc(debugFunc), taskfile.WithPromptFunc(promptFunc), ) diff --git a/taskfile/node.go b/taskfile/node.go index 5d5f778762..9ae936d5a1 100644 --- a/taskfile/node.go +++ b/taskfile/node.go @@ -34,13 +34,14 @@ func NewRootNode( dir string, insecure bool, timeout time.Duration, + opts ...NodeOption, ) (Node, error) { dir = fsext.DefaultDir(entrypoint, dir) // If the entrypoint is "-", we read from stdin if entrypoint == "-" { return NewStdinNode(dir) } - return NewNode(entrypoint, dir, insecure) + return NewNode(entrypoint, dir, insecure, opts...) } func NewNode( diff --git a/taskfile/node_base.go b/taskfile/node_base.go index 7e641efdba..2d81dded51 100644 --- a/taskfile/node_base.go +++ b/taskfile/node_base.go @@ -10,6 +10,9 @@ type ( parent Node dir string checksum string + caCert string + cert string + certKey string } ) @@ -54,3 +57,21 @@ func (node *baseNode) Checksum() string { func (node *baseNode) Verify(checksum string) bool { return node.checksum == "" || node.checksum == checksum } + +func WithCACert(caCert string) NodeOption { + return func(node *baseNode) { + node.caCert = caCert + } +} + +func WithCert(cert string) NodeOption { + return func(node *baseNode) { + node.cert = cert + } +} + +func WithCertKey(certKey string) NodeOption { + return func(node *baseNode) { + node.certKey = certKey + } +} diff --git a/taskfile/node_http.go b/taskfile/node_http.go index 3db520d41f..914b8b9a51 100644 --- a/taskfile/node_http.go +++ b/taskfile/node_http.go @@ -2,10 +2,13 @@ package taskfile import ( "context" + "crypto/tls" + "crypto/x509" "fmt" "io" "net/http" "net/url" + "os" "path/filepath" "strings" @@ -17,7 +20,54 @@ import ( // An HTTPNode is a node that reads a Taskfile from a remote location via HTTP. type HTTPNode struct { *baseNode - url *url.URL // stores url pointing actual remote file. (e.g. with Taskfile.yml) + url *url.URL // stores url pointing actual remote file. (e.g. with Taskfile.yml) + client *http.Client // HTTP client with optional TLS configuration +} + +// buildHTTPClient creates an HTTP client with optional TLS configuration. +// If no certificate options are provided, it returns http.DefaultClient. +func buildHTTPClient(insecure bool, caCert, cert, certKey string) (*http.Client, error) { + // Validate that cert and certKey are provided together + if (cert != "" && certKey == "") || (cert == "" && certKey != "") { + return nil, fmt.Errorf("both --cert and --cert-key must be provided together") + } + + // If no TLS customization is needed, return the default client + if !insecure && caCert == "" && cert == "" { + return http.DefaultClient, nil + } + + tlsConfig := &tls.Config{ + InsecureSkipVerify: insecure, + } + + // Load custom CA certificate if provided + if caCert != "" { + caCertData, err := os.ReadFile(caCert) + if err != nil { + return nil, fmt.Errorf("failed to read CA certificate: %w", err) + } + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCertData) { + return nil, fmt.Errorf("failed to parse CA certificate") + } + tlsConfig.RootCAs = caCertPool + } + + // Load client certificate and key if provided + if cert != "" && certKey != "" { + clientCert, err := tls.LoadX509KeyPair(cert, certKey) + if err != nil { + return nil, fmt.Errorf("failed to load client certificate: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{clientCert} + } + + return &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, + }, nil } func NewHTTPNode( @@ -34,9 +84,16 @@ func NewHTTPNode( if url.Scheme == "http" && !insecure { return nil, &errors.TaskfileNotSecureError{URI: url.Redacted()} } + + client, err := buildHTTPClient(insecure, base.caCert, base.cert, base.certKey) + if err != nil { + return nil, err + } + return &HTTPNode{ baseNode: base, url: url, + client: client, }, nil } @@ -49,7 +106,7 @@ func (node *HTTPNode) Read() ([]byte, error) { } func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) { - url, err := RemoteExists(ctx, *node.url) + url, err := RemoteExists(ctx, *node.url, node.client) if err != nil { return nil, err } @@ -58,7 +115,7 @@ func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) { return nil, errors.TaskfileFetchFailedError{URI: node.Location()} } - resp, err := http.DefaultClient.Do(req.WithContext(ctx)) + resp, err := node.client.Do(req.WithContext(ctx)) if err != nil { if ctx.Err() != nil { return nil, err diff --git a/taskfile/node_http_test.go b/taskfile/node_http_test.go index ade7c905b9..359ec798bf 100644 --- a/taskfile/node_http_test.go +++ b/taskfile/node_http_test.go @@ -1,7 +1,18 @@ package taskfile import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net/http" + "os" + "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -47,3 +58,227 @@ func TestHTTPNode_CacheKey(t *testing.T) { assert.Equal(t, tt.expectedKey, key) } } + +func TestBuildHTTPClient_Default(t *testing.T) { + t.Parallel() + + // When no TLS customization is needed, should return http.DefaultClient + client, err := buildHTTPClient(false, "", "", "") + require.NoError(t, err) + assert.Equal(t, http.DefaultClient, client) +} + +func TestBuildHTTPClient_Insecure(t *testing.T) { + t.Parallel() + + client, err := buildHTTPClient(true, "", "", "") + require.NoError(t, err) + require.NotNil(t, client) + assert.NotEqual(t, http.DefaultClient, client) + + // Check that InsecureSkipVerify is set + transport, ok := client.Transport.(*http.Transport) + require.True(t, ok) + require.NotNil(t, transport.TLSClientConfig) + assert.True(t, transport.TLSClientConfig.InsecureSkipVerify) +} + +func TestBuildHTTPClient_CACert(t *testing.T) { + t.Parallel() + + // Create a temporary CA cert file + tempDir := t.TempDir() + caCertPath := filepath.Join(tempDir, "ca.crt") + + // Generate a valid CA certificate + caCertPEM := generateTestCACert(t) + err := os.WriteFile(caCertPath, caCertPEM, 0o600) + require.NoError(t, err) + + client, err := buildHTTPClient(false, caCertPath, "", "") + require.NoError(t, err) + require.NotNil(t, client) + assert.NotEqual(t, http.DefaultClient, client) + + // Check that custom RootCAs is set + transport, ok := client.Transport.(*http.Transport) + require.True(t, ok) + require.NotNil(t, transport.TLSClientConfig) + assert.NotNil(t, transport.TLSClientConfig.RootCAs) +} + +func TestBuildHTTPClient_CACertNotFound(t *testing.T) { + t.Parallel() + + client, err := buildHTTPClient(false, "/nonexistent/ca.crt", "", "") + assert.Error(t, err) + assert.Nil(t, client) + assert.Contains(t, err.Error(), "failed to read CA certificate") +} + +func TestBuildHTTPClient_CACertInvalid(t *testing.T) { + t.Parallel() + + // Create a temporary file with invalid content + tempDir := t.TempDir() + caCertPath := filepath.Join(tempDir, "invalid.crt") + err := os.WriteFile(caCertPath, []byte("not a valid certificate"), 0o600) + require.NoError(t, err) + + client, err := buildHTTPClient(false, caCertPath, "", "") + assert.Error(t, err) + assert.Nil(t, client) + assert.Contains(t, err.Error(), "failed to parse CA certificate") +} + +func TestBuildHTTPClient_CertWithoutKey(t *testing.T) { + t.Parallel() + + client, err := buildHTTPClient(false, "", "/path/to/cert.crt", "") + assert.Error(t, err) + assert.Nil(t, client) + assert.Contains(t, err.Error(), "both --cert and --cert-key must be provided together") +} + +func TestBuildHTTPClient_KeyWithoutCert(t *testing.T) { + t.Parallel() + + client, err := buildHTTPClient(false, "", "", "/path/to/key.pem") + assert.Error(t, err) + assert.Nil(t, client) + assert.Contains(t, err.Error(), "both --cert and --cert-key must be provided together") +} + +func TestBuildHTTPClient_CertAndKey(t *testing.T) { + t.Parallel() + + // Create temporary cert and key files + tempDir := t.TempDir() + certPath := filepath.Join(tempDir, "client.crt") + keyPath := filepath.Join(tempDir, "client.key") + + // Generate a self-signed certificate and key for testing + cert, key := generateTestCertAndKey(t) + err := os.WriteFile(certPath, cert, 0o600) + require.NoError(t, err) + err = os.WriteFile(keyPath, key, 0o600) + require.NoError(t, err) + + client, err := buildHTTPClient(false, "", certPath, keyPath) + require.NoError(t, err) + require.NotNil(t, client) + assert.NotEqual(t, http.DefaultClient, client) + + // Check that client certificate is set + transport, ok := client.Transport.(*http.Transport) + require.True(t, ok) + require.NotNil(t, transport.TLSClientConfig) + assert.Len(t, transport.TLSClientConfig.Certificates, 1) +} + +func TestBuildHTTPClient_CertNotFound(t *testing.T) { + t.Parallel() + + client, err := buildHTTPClient(false, "", "/nonexistent/cert.crt", "/nonexistent/key.pem") + assert.Error(t, err) + assert.Nil(t, client) + assert.Contains(t, err.Error(), "failed to load client certificate") +} + +func TestBuildHTTPClient_InsecureWithCACert(t *testing.T) { + t.Parallel() + + // Create a temporary CA cert file + tempDir := t.TempDir() + caCertPath := filepath.Join(tempDir, "ca.crt") + + // Generate a valid CA certificate + caCertPEM := generateTestCACert(t) + err := os.WriteFile(caCertPath, caCertPEM, 0o600) + require.NoError(t, err) + + // Both insecure and CA cert can be set together + client, err := buildHTTPClient(true, caCertPath, "", "") + require.NoError(t, err) + require.NotNil(t, client) + + transport, ok := client.Transport.(*http.Transport) + require.True(t, ok) + require.NotNil(t, transport.TLSClientConfig) + assert.True(t, transport.TLSClientConfig.InsecureSkipVerify) + assert.NotNil(t, transport.TLSClientConfig.RootCAs) +} + +// generateTestCertAndKey generates a self-signed certificate and key for testing +func generateTestCertAndKey(t *testing.T) (certPEM, keyPEM []byte) { + t.Helper() + + // Generate a new ECDSA private key + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // Create a certificate template + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Task Org"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + + // Create the certificate + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + require.NoError(t, err) + + // Encode certificate to PEM + certPEM = pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certDER, + }) + + // Encode private key to PEM + keyDER, err := x509.MarshalECPrivateKey(privateKey) + require.NoError(t, err) + keyPEM = pem.EncodeToMemory(&pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: keyDER, + }) + + return certPEM, keyPEM +} + +// generateTestCACert generates a self-signed CA certificate for testing +func generateTestCACert(t *testing.T) []byte { + t.Helper() + + // Generate a new ECDSA private key + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // Create a CA certificate template + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Test CA"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + IsCA: true, + BasicConstraintsValid: true, + } + + // Create the certificate + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + require.NoError(t, err) + + // Encode certificate to PEM + return pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: certDER, + }) +} diff --git a/taskfile/reader.go b/taskfile/reader.go index 402c3f7214..83fcecddef 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -45,6 +45,9 @@ type ( offline bool tempDir string cacheExpiryDuration time.Duration + caCert string + cert string + certKey string debugFunc DebugFunc promptFunc PromptFunc promptMutex sync.Mutex @@ -182,6 +185,45 @@ func (o *promptFuncOption) ApplyToReader(r *Reader) { r.promptFunc = o.promptFunc } +// WithReaderCACert sets the path to a custom CA certificate for TLS connections. +func WithReaderCACert(caCert string) ReaderOption { + return &readerCACertOption{caCert: caCert} +} + +type readerCACertOption struct { + caCert string +} + +func (o *readerCACertOption) ApplyToReader(r *Reader) { + r.caCert = o.caCert +} + +// WithReaderCert sets the path to a client certificate for TLS connections. +func WithReaderCert(cert string) ReaderOption { + return &readerCertOption{cert: cert} +} + +type readerCertOption struct { + cert string +} + +func (o *readerCertOption) ApplyToReader(r *Reader) { + r.cert = o.cert +} + +// WithReaderCertKey sets the path to a client certificate key for TLS connections. +func WithReaderCertKey(certKey string) ReaderOption { + return &readerCertKeyOption{certKey: certKey} +} + +type readerCertKeyOption struct { + certKey string +} + +func (o *readerCertKeyOption) ApplyToReader(r *Reader) { + r.certKey = o.certKey +} + // Read will read the Taskfile defined by the [Reader]'s [Node] and recurse // through any [ast.Includes] it finds, reading each included Taskfile and // building an [ast.TaskfileGraph] as it goes. If any errors occur, they will be @@ -269,6 +311,9 @@ func (r *Reader) include(ctx context.Context, node Node) error { includeNode, err := NewNode(entrypoint, include.Dir, r.insecure, WithParent(node), WithChecksum(include.Checksum), + WithCACert(r.caCert), + WithCert(r.cert), + WithCertKey(r.certKey), ) if err != nil { if include.Optional { diff --git a/taskfile/taskfile.go b/taskfile/taskfile.go index e209444acc..24bd118350 100644 --- a/taskfile/taskfile.go +++ b/taskfile/taskfile.go @@ -36,7 +36,7 @@ var ( // at the given URL with any of the default Taskfile files names. If any of // these match a file, the first matching path will be returned. If no files are // found, an error will be returned. -func RemoteExists(ctx context.Context, u url.URL) (*url.URL, error) { +func RemoteExists(ctx context.Context, u url.URL, client *http.Client) (*url.URL, error) { // Create a new HEAD request for the given URL to check if the resource exists req, err := http.NewRequestWithContext(ctx, "HEAD", u.String(), nil) if err != nil { @@ -44,7 +44,7 @@ func RemoteExists(ctx context.Context, u url.URL) (*url.URL, error) { } // Request the given URL - resp, err := http.DefaultClient.Do(req) + resp, err := client.Do(req) if err != nil { if ctx.Err() != nil { return nil, fmt.Errorf("checking remote file: %w", ctx.Err()) @@ -76,7 +76,7 @@ func RemoteExists(ctx context.Context, u url.URL) (*url.URL, error) { req.URL = alt // Try the alternative URL - resp, err = http.DefaultClient.Do(req) + resp, err = client.Do(req) if err != nil { return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()} } diff --git a/taskrc/ast/taskrc.go b/taskrc/ast/taskrc.go index 9410ed1738..27f976f4a0 100644 --- a/taskrc/ast/taskrc.go +++ b/taskrc/ast/taskrc.go @@ -21,6 +21,9 @@ type Remote struct { Offline *bool `yaml:"offline"` Timeout *time.Duration `yaml:"timeout"` CacheExpiry *time.Duration `yaml:"cache-expiry"` + CACert *string `yaml:"cacert"` + Cert *string `yaml:"cert"` + CertKey *string `yaml:"cert-key"` } // Merge combines the current TaskRC with another TaskRC, prioritizing non-nil fields from the other TaskRC. @@ -42,6 +45,9 @@ func (t *TaskRC) Merge(other *TaskRC) { t.Remote.Offline = cmp.Or(other.Remote.Offline, t.Remote.Offline) t.Remote.Timeout = cmp.Or(other.Remote.Timeout, t.Remote.Timeout) t.Remote.CacheExpiry = cmp.Or(other.Remote.CacheExpiry, t.Remote.CacheExpiry) + t.Remote.CACert = cmp.Or(other.Remote.CACert, t.Remote.CACert) + t.Remote.Cert = cmp.Or(other.Remote.Cert, t.Remote.Cert) + t.Remote.CertKey = cmp.Or(other.Remote.CertKey, t.Remote.CertKey) t.Verbose = cmp.Or(other.Verbose, t.Verbose) t.Concurrency = cmp.Or(other.Concurrency, t.Concurrency) diff --git a/website/src/docs/experiments/remote-taskfiles.md b/website/src/docs/experiments/remote-taskfiles.md index 88a8cc799f..4e25b2e6ee 100644 --- a/website/src/docs/experiments/remote-taskfiles.md +++ b/website/src/docs/experiments/remote-taskfiles.md @@ -260,6 +260,38 @@ Taskfile that is downloaded via an unencrypted connection. Sources that are not protected by TLS are vulnerable to man-in-the-middle attacks and should be avoided unless you know what you are doing. +#### Custom Certificates + +If your remote Taskfiles are hosted on a server that uses a custom CA +certificate (e.g., a corporate internal server), you can specify the CA +certificate using the `--cacert` flag: + +```shell +task --taskfile https://internal.example.com/Taskfile.yml --cacert /path/to/ca.crt +``` + +For servers that require client certificate authentication (mTLS), you can +provide a client certificate and key: + +```shell +task --taskfile https://secure.example.com/Taskfile.yml \ + --cert /path/to/client.crt \ + --cert-key /path/to/client.key +``` + +::: warning + +Encrypted private keys are not currently supported. If your key is encrypted, +you must decrypt it first: + +```shell +openssl rsa -in encrypted.key -out decrypted.key +``` + +::: + +These options can also be configured in the [configuration file](#configuration). + ## Caching & Running Offline Whenever you run a remote Taskfile, the latest copy will be downloaded from the @@ -305,6 +337,9 @@ remote: offline: false timeout: "30s" cache-expiry: "24h" + cacert: "" + cert: "" + cert-key: "" ``` #### `insecure` @@ -353,3 +388,36 @@ remote: remote: cache-expiry: "6h" ``` + +#### `cacert` + +- **Type**: `string` +- **Default**: `""` +- **Description**: Path to a custom CA certificate file for TLS verification + +```yaml +remote: + cacert: "/path/to/ca.crt" +``` + +#### `cert` + +- **Type**: `string` +- **Default**: `""` +- **Description**: Path to a client certificate file for mTLS authentication + +```yaml +remote: + cert: "/path/to/client.crt" +``` + +#### `cert-key` + +- **Type**: `string` +- **Default**: `""` +- **Description**: Path to the client certificate private key file + +```yaml +remote: + cert-key: "/path/to/client.key" +```