From 723d9f052731a67b631ffaf4d1cbe9e7e7a0e52b Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 29 Nov 2025 18:37:41 +0100 Subject: [PATCH 1/5] feat: support self-signed certificates for remote taskfiles --- executor.go | 56 +++++ internal/flags/flags.go | 21 ++ setup.go | 11 +- taskfile/node.go | 3 +- taskfile/node_base.go | 34 ++- taskfile/node_http.go | 112 ++++++++- taskfile/node_http_test.go | 235 ++++++++++++++++++ taskfile/reader.go | 60 +++++ taskfile/taskfile.go | 6 +- taskrc/ast/taskrc.go | 8 + .../src/docs/experiments/remote-taskfiles.md | 80 ++++++ 11 files changed, 615 insertions(+), 11 deletions(-) diff --git a/executor.go b/executor.go index 6ecf910a5b..68ff79c2b6 100644 --- a/executor.go +++ b/executor.go @@ -36,6 +36,10 @@ type ( Offline bool Timeout time.Duration CacheExpiryDuration time.Duration + CACert string + Cert string + CertKey string + CertKeyPass string Watch bool Verbose bool Silent bool @@ -253,6 +257,58 @@ 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 +} + +// WithCertKeyPass sets the passphrase for the client certificate key. +func WithCertKeyPass(certKeyPass string) ExecutorOption { + return &certKeyPassOption{certKeyPass: certKeyPass} +} + +type certKeyPassOption struct { + certKeyPass string +} + +func (o *certKeyPassOption) ApplyToExecutor(e *Executor) { + e.CertKeyPass = o.certKeyPass +} + // 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..e2431980bd 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -76,6 +76,10 @@ var ( ClearCache bool Timeout time.Duration CacheExpiryDuration time.Duration + CACert string + Cert string + CertKey string + CertKeyPass string ) func init() { @@ -155,6 +159,10 @@ 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.StringVar(&CertKeyPass, "cert-key-pass", getConfig(config, func() *string { return config.Remote.CertKeyPass }, ""), "Passphrase for the client certificate key.") } pflag.Parse() } @@ -200,6 +208,15 @@ 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") + } + + if CertKeyPass != "" && Cert == "" { + return errors.New("task: --cert-key-pass requires --cert and --cert-key") + } + return nil } @@ -240,6 +257,10 @@ 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.WithCertKeyPass(CertKeyPass), task.WithWatch(Watch), task.WithVerbose(Verbose), task.WithSilent(Silent), diff --git a/setup.go b/setup.go index 6234ceefa0..b65d29b0a9 100644 --- a/setup.go +++ b/setup.go @@ -56,7 +56,12 @@ 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), + taskfile.WithCertKeyPass(e.CertKeyPass), + ) if os.IsNotExist(err) { return nil, errors.TaskfileNotFoundError{ URI: fsext.DefaultDir(e.Entrypoint, e.Dir), @@ -86,6 +91,10 @@ 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.WithReaderCertKeyPass(e.CertKeyPass), 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..c1dc27c2db 100644 --- a/taskfile/node_base.go +++ b/taskfile/node_base.go @@ -7,9 +7,13 @@ type ( // designed to be embedded in other node types so that this boilerplate code // does not need to be repeated. baseNode struct { - parent Node - dir string - checksum string + parent Node + dir string + checksum string + caCert string + cert string + certKey string + certKeyPass string } ) @@ -54,3 +58,27 @@ 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 + } +} + +func WithCertKeyPass(certKeyPass string) NodeOption { + return func(node *baseNode) { + node.certKeyPass = certKeyPass + } +} diff --git a/taskfile/node_http.go b/taskfile/node_http.go index 3db520d41f..4682d7516d 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,102 @@ 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, certKeyPass 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, //nolint:gosec + } + + // 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 != "" { + var clientCert tls.Certificate + var err error + + if certKeyPass != "" { + // Load encrypted private key + clientCert, err = loadCertWithEncryptedKey(cert, certKey, certKeyPass) + } else { + 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 +} + +// loadCertWithEncryptedKey loads a certificate with an encrypted private key. +func loadCertWithEncryptedKey(certFile, keyFile, passphrase string) (tls.Certificate, error) { + certPEM, err := os.ReadFile(certFile) + if err != nil { + return tls.Certificate{}, fmt.Errorf("failed to read certificate file: %w", err) + } + + keyPEM, err := os.ReadFile(keyFile) + if err != nil { + return tls.Certificate{}, fmt.Errorf("failed to read key file: %w", err) + } + + // Try to decrypt the private key + decryptedKey, err := decryptPEMKey(keyPEM, passphrase) + if err != nil { + return tls.Certificate{}, fmt.Errorf("failed to decrypt private key: %w", err) + } + + return tls.X509KeyPair(certPEM, decryptedKey) +} + +// decryptPEMKey attempts to decrypt a PEM-encoded private key. +func decryptPEMKey(keyPEM []byte, passphrase string) ([]byte, error) { + // For PKCS#8 encrypted keys, we need to parse and decrypt them + // The standard library doesn't directly support encrypted PKCS#8, + // so we try to parse it as-is first (in case it's not actually encrypted) + // For now, we support unencrypted keys and return an error for encrypted ones + // that require external libraries to decrypt. + + // Try to parse as unencrypted first + _, err := tls.X509KeyPair([]byte("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"), keyPEM) + if err == nil { + return keyPEM, nil + } + + // TODO: Add support for encrypted PKCS#8 keys using x/crypto/pkcs8 + // This would require adding a dependency on golang.org/x/crypto + return nil, fmt.Errorf("encrypted private keys require the key to be decrypted externally, or use an unencrypted key") } func NewHTTPNode( @@ -34,9 +132,17 @@ func NewHTTPNode( if url.Scheme == "http" && !insecure { return nil, &errors.TaskfileNotSecureError{URI: url.Redacted()} } + + // Build HTTP client with TLS configuration from node options + client, err := buildHTTPClient(insecure, base.caCert, base.cert, base.certKey, base.certKeyPass) + if err != nil { + return nil, err + } + return &HTTPNode{ baseNode: base, url: url, + client: client, }, nil } @@ -49,7 +155,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 +164,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..85bf0b84cb 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, 0600) + 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"), 0600) + 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, 0600) + require.NoError(t, err) + err = os.WriteFile(keyPath, key, 0600) + 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, 0600) + 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{"Test 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..44c45906be 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -45,6 +45,10 @@ type ( offline bool tempDir string cacheExpiryDuration time.Duration + caCert string + cert string + certKey string + certKeyPass string debugFunc DebugFunc promptFunc PromptFunc promptMutex sync.Mutex @@ -182,6 +186,58 @@ 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 +} + +// WithReaderCertKeyPass sets the passphrase for the client certificate key. +func WithReaderCertKeyPass(certKeyPass string) ReaderOption { + return &readerCertKeyPassOption{certKeyPass: certKeyPass} +} + +type readerCertKeyPassOption struct { + certKeyPass string +} + +func (o *readerCertKeyPassOption) ApplyToReader(r *Reader) { + r.certKeyPass = o.certKeyPass +} + // 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 +325,10 @@ 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), + WithCertKeyPass(r.certKeyPass), ) 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..fd1db957ba 100644 --- a/taskrc/ast/taskrc.go +++ b/taskrc/ast/taskrc.go @@ -21,6 +21,10 @@ 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"` + CertKeyPass *string `yaml:"cert-key-pass"` } // Merge combines the current TaskRC with another TaskRC, prioritizing non-nil fields from the other TaskRC. @@ -42,6 +46,10 @@ 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.Remote.CertKeyPass = cmp.Or(other.Remote.CertKeyPass, t.Remote.CertKeyPass) 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..8665006614 100644 --- a/website/src/docs/experiments/remote-taskfiles.md +++ b/website/src/docs/experiments/remote-taskfiles.md @@ -260,6 +260,37 @@ 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 +``` + +If your private key is encrypted with a passphrase, you can provide it using +the `--cert-key-pass` flag: + +```shell +task --taskfile https://secure.example.com/Taskfile.yml \ + --cert /path/to/client.crt \ + --cert-key /path/to/client.key \ + --cert-key-pass "your-passphrase" +``` + +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 +336,10 @@ remote: offline: false timeout: "30s" cache-expiry: "24h" + cacert: "" + cert: "" + cert-key: "" + cert-key-pass: "" ``` #### `insecure` @@ -353,3 +388,48 @@ 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" +``` + +#### `cert-key-pass` + +- **Type**: `string` +- **Default**: `""` +- **Description**: Passphrase for the client certificate private key (if + encrypted) + +```yaml +remote: + cert-key-pass: "your-passphrase" +``` From 8e8eb13d69e4ebca552d545c99aa71dbc50a07fd Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 29 Nov 2025 18:41:57 +0100 Subject: [PATCH 2/5] completions --- completion/fish/task.fish | 10 +++++++--- completion/ps/task.ps1 | 4 ++++ completion/zsh/_task | 4 ++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/completion/fish/task.fish b/completion/fish/task.fish index 3b7683fe02..9a596d2219 100644 --- a/completion/fish/task.fish +++ b/completion/fish/task.fish @@ -102,9 +102,13 @@ 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 +complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cert-key-pass -d 'passphrase for private key' # 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..00b341f49b 100644 --- a/completion/ps/task.ps1 +++ b/completion/ps/task.ps1 @@ -72,6 +72,10 @@ 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') + $completions += [CompletionResult]::new('--cert-key-pass', '--cert-key-pass', [CompletionResultType]::ParameterName, 'private key passphrase') # 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..908dac53aa 100755 --- a/completion/zsh/_task +++ b/completion/zsh/_task @@ -86,6 +86,10 @@ _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' + '(--cert-key-pass)--cert-key-pass[passphrase for private key]: ' ) fi From fa2f6ffd4d3c0bb6ba9f95a3d58bf4f413febcb4 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 29 Nov 2025 19:38:01 +0100 Subject: [PATCH 3/5] remove passphrase --- completion/fish/task.fish | 7 ++- completion/ps/task.ps1 | 1 - completion/zsh/_task | 1 - executor.go | 14 ----- internal/flags/flags.go | 7 --- setup.go | 2 - taskfile/node_base.go | 19 +++---- taskfile/node_http.go | 54 ++----------------- taskfile/node_http_test.go | 30 +++++------ taskfile/reader.go | 15 ------ taskrc/ast/taskrc.go | 2 - .../src/docs/experiments/remote-taskfiles.md | 26 +++------ 12 files changed, 34 insertions(+), 144 deletions(-) diff --git a/completion/fish/task.fish b/completion/fish/task.fish index 9a596d2219..725b3fbc7b 100644 --- a/completion/fish/task.fish +++ b/completion/fish/task.fish @@ -105,10 +105,9 @@ complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled GENTLE_FORCE" -l 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 -complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l cert-key-pass -d 'passphrase for private key' +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 00b341f49b..310a3b9c57 100644 --- a/completion/ps/task.ps1 +++ b/completion/ps/task.ps1 @@ -75,7 +75,6 @@ Register-ArgumentCompleter -CommandName task -ScriptBlock { $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') - $completions += [CompletionResult]::new('--cert-key-pass', '--cert-key-pass', [CompletionResultType]::ParameterName, 'private key passphrase') # 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 908dac53aa..41db8157d5 100755 --- a/completion/zsh/_task +++ b/completion/zsh/_task @@ -89,7 +89,6 @@ _task() { '(--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' - '(--cert-key-pass)--cert-key-pass[passphrase for private key]: ' ) fi diff --git a/executor.go b/executor.go index 68ff79c2b6..05786adc32 100644 --- a/executor.go +++ b/executor.go @@ -39,7 +39,6 @@ type ( CACert string Cert string CertKey string - CertKeyPass string Watch bool Verbose bool Silent bool @@ -296,19 +295,6 @@ func (o *certKeyOption) ApplyToExecutor(e *Executor) { e.CertKey = o.certKey } -// WithCertKeyPass sets the passphrase for the client certificate key. -func WithCertKeyPass(certKeyPass string) ExecutorOption { - return &certKeyPassOption{certKeyPass: certKeyPass} -} - -type certKeyPassOption struct { - certKeyPass string -} - -func (o *certKeyPassOption) ApplyToExecutor(e *Executor) { - e.CertKeyPass = o.certKeyPass -} - // 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 e2431980bd..e1251631cf 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -79,7 +79,6 @@ var ( CACert string Cert string CertKey string - CertKeyPass string ) func init() { @@ -162,7 +161,6 @@ func init() { 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.StringVar(&CertKeyPass, "cert-key-pass", getConfig(config, func() *string { return config.Remote.CertKeyPass }, ""), "Passphrase for the client certificate key.") } pflag.Parse() } @@ -213,10 +211,6 @@ func Validate() error { return errors.New("task: --cert and --cert-key must be provided together") } - if CertKeyPass != "" && Cert == "" { - return errors.New("task: --cert-key-pass requires --cert and --cert-key") - } - return nil } @@ -260,7 +254,6 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) { task.WithCACert(CACert), task.WithCert(Cert), task.WithCertKey(CertKey), - task.WithCertKeyPass(CertKeyPass), task.WithWatch(Watch), task.WithVerbose(Verbose), task.WithSilent(Silent), diff --git a/setup.go b/setup.go index b65d29b0a9..837fb9aaf9 100644 --- a/setup.go +++ b/setup.go @@ -60,7 +60,6 @@ func (e *Executor) getRootNode() (taskfile.Node, error) { taskfile.WithCACert(e.CACert), taskfile.WithCert(e.Cert), taskfile.WithCertKey(e.CertKey), - taskfile.WithCertKeyPass(e.CertKeyPass), ) if os.IsNotExist(err) { return nil, errors.TaskfileNotFoundError{ @@ -94,7 +93,6 @@ func (e *Executor) readTaskfile(node taskfile.Node) error { taskfile.WithReaderCACert(e.CACert), taskfile.WithReaderCert(e.Cert), taskfile.WithReaderCertKey(e.CertKey), - taskfile.WithReaderCertKeyPass(e.CertKeyPass), taskfile.WithDebugFunc(debugFunc), taskfile.WithPromptFunc(promptFunc), ) diff --git a/taskfile/node_base.go b/taskfile/node_base.go index c1dc27c2db..2d81dded51 100644 --- a/taskfile/node_base.go +++ b/taskfile/node_base.go @@ -7,13 +7,12 @@ type ( // designed to be embedded in other node types so that this boilerplate code // does not need to be repeated. baseNode struct { - parent Node - dir string - checksum string - caCert string - cert string - certKey string - certKeyPass string + parent Node + dir string + checksum string + caCert string + cert string + certKey string } ) @@ -76,9 +75,3 @@ func WithCertKey(certKey string) NodeOption { node.certKey = certKey } } - -func WithCertKeyPass(certKeyPass string) NodeOption { - return func(node *baseNode) { - node.certKeyPass = certKeyPass - } -} diff --git a/taskfile/node_http.go b/taskfile/node_http.go index 4682d7516d..72a2e273a7 100644 --- a/taskfile/node_http.go +++ b/taskfile/node_http.go @@ -26,7 +26,7 @@ type HTTPNode struct { // 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, certKeyPass string) (*http.Client, error) { +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") @@ -56,15 +56,7 @@ func buildHTTPClient(insecure bool, caCert, cert, certKey, certKeyPass string) ( // Load client certificate and key if provided if cert != "" && certKey != "" { - var clientCert tls.Certificate - var err error - - if certKeyPass != "" { - // Load encrypted private key - clientCert, err = loadCertWithEncryptedKey(cert, certKey, certKeyPass) - } else { - clientCert, err = tls.LoadX509KeyPair(cert, certKey) - } + clientCert, err := tls.LoadX509KeyPair(cert, certKey) if err != nil { return nil, fmt.Errorf("failed to load client certificate: %w", err) } @@ -78,46 +70,6 @@ func buildHTTPClient(insecure bool, caCert, cert, certKey, certKeyPass string) ( }, nil } -// loadCertWithEncryptedKey loads a certificate with an encrypted private key. -func loadCertWithEncryptedKey(certFile, keyFile, passphrase string) (tls.Certificate, error) { - certPEM, err := os.ReadFile(certFile) - if err != nil { - return tls.Certificate{}, fmt.Errorf("failed to read certificate file: %w", err) - } - - keyPEM, err := os.ReadFile(keyFile) - if err != nil { - return tls.Certificate{}, fmt.Errorf("failed to read key file: %w", err) - } - - // Try to decrypt the private key - decryptedKey, err := decryptPEMKey(keyPEM, passphrase) - if err != nil { - return tls.Certificate{}, fmt.Errorf("failed to decrypt private key: %w", err) - } - - return tls.X509KeyPair(certPEM, decryptedKey) -} - -// decryptPEMKey attempts to decrypt a PEM-encoded private key. -func decryptPEMKey(keyPEM []byte, passphrase string) ([]byte, error) { - // For PKCS#8 encrypted keys, we need to parse and decrypt them - // The standard library doesn't directly support encrypted PKCS#8, - // so we try to parse it as-is first (in case it's not actually encrypted) - // For now, we support unencrypted keys and return an error for encrypted ones - // that require external libraries to decrypt. - - // Try to parse as unencrypted first - _, err := tls.X509KeyPair([]byte("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"), keyPEM) - if err == nil { - return keyPEM, nil - } - - // TODO: Add support for encrypted PKCS#8 keys using x/crypto/pkcs8 - // This would require adding a dependency on golang.org/x/crypto - return nil, fmt.Errorf("encrypted private keys require the key to be decrypted externally, or use an unencrypted key") -} - func NewHTTPNode( entrypoint string, dir string, @@ -134,7 +86,7 @@ func NewHTTPNode( } // Build HTTP client with TLS configuration from node options - client, err := buildHTTPClient(insecure, base.caCert, base.cert, base.certKey, base.certKeyPass) + client, err := buildHTTPClient(insecure, base.caCert, base.cert, base.certKey) if err != nil { return nil, err } diff --git a/taskfile/node_http_test.go b/taskfile/node_http_test.go index 85bf0b84cb..cfa273294b 100644 --- a/taskfile/node_http_test.go +++ b/taskfile/node_http_test.go @@ -63,7 +63,7 @@ func TestBuildHTTPClient_Default(t *testing.T) { t.Parallel() // When no TLS customization is needed, should return http.DefaultClient - client, err := buildHTTPClient(false, "", "", "", "") + client, err := buildHTTPClient(false, "", "", "") require.NoError(t, err) assert.Equal(t, http.DefaultClient, client) } @@ -71,7 +71,7 @@ func TestBuildHTTPClient_Default(t *testing.T) { func TestBuildHTTPClient_Insecure(t *testing.T) { t.Parallel() - client, err := buildHTTPClient(true, "", "", "", "") + client, err := buildHTTPClient(true, "", "", "") require.NoError(t, err) require.NotNil(t, client) assert.NotEqual(t, http.DefaultClient, client) @@ -92,10 +92,10 @@ func TestBuildHTTPClient_CACert(t *testing.T) { // Generate a valid CA certificate caCertPEM := generateTestCACert(t) - err := os.WriteFile(caCertPath, caCertPEM, 0600) + err := os.WriteFile(caCertPath, caCertPEM, 0o600) require.NoError(t, err) - client, err := buildHTTPClient(false, caCertPath, "", "", "") + client, err := buildHTTPClient(false, caCertPath, "", "") require.NoError(t, err) require.NotNil(t, client) assert.NotEqual(t, http.DefaultClient, client) @@ -110,7 +110,7 @@ func TestBuildHTTPClient_CACert(t *testing.T) { func TestBuildHTTPClient_CACertNotFound(t *testing.T) { t.Parallel() - client, err := buildHTTPClient(false, "/nonexistent/ca.crt", "", "", "") + 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") @@ -122,10 +122,10 @@ func TestBuildHTTPClient_CACertInvalid(t *testing.T) { // 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"), 0600) + err := os.WriteFile(caCertPath, []byte("not a valid certificate"), 0o600) require.NoError(t, err) - client, err := buildHTTPClient(false, caCertPath, "", "", "") + client, err := buildHTTPClient(false, caCertPath, "", "") assert.Error(t, err) assert.Nil(t, client) assert.Contains(t, err.Error(), "failed to parse CA certificate") @@ -134,7 +134,7 @@ func TestBuildHTTPClient_CACertInvalid(t *testing.T) { func TestBuildHTTPClient_CertWithoutKey(t *testing.T) { t.Parallel() - client, err := buildHTTPClient(false, "", "/path/to/cert.crt", "", "") + 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") @@ -143,7 +143,7 @@ func TestBuildHTTPClient_CertWithoutKey(t *testing.T) { func TestBuildHTTPClient_KeyWithoutCert(t *testing.T) { t.Parallel() - client, err := buildHTTPClient(false, "", "", "/path/to/key.pem", "") + 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") @@ -159,12 +159,12 @@ func TestBuildHTTPClient_CertAndKey(t *testing.T) { // Generate a self-signed certificate and key for testing cert, key := generateTestCertAndKey(t) - err := os.WriteFile(certPath, cert, 0600) + err := os.WriteFile(certPath, cert, 0o600) require.NoError(t, err) - err = os.WriteFile(keyPath, key, 0600) + err = os.WriteFile(keyPath, key, 0o600) require.NoError(t, err) - client, err := buildHTTPClient(false, "", certPath, keyPath, "") + client, err := buildHTTPClient(false, "", certPath, keyPath) require.NoError(t, err) require.NotNil(t, client) assert.NotEqual(t, http.DefaultClient, client) @@ -179,7 +179,7 @@ func TestBuildHTTPClient_CertAndKey(t *testing.T) { func TestBuildHTTPClient_CertNotFound(t *testing.T) { t.Parallel() - client, err := buildHTTPClient(false, "", "/nonexistent/cert.crt", "/nonexistent/key.pem", "") + 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") @@ -194,11 +194,11 @@ func TestBuildHTTPClient_InsecureWithCACert(t *testing.T) { // Generate a valid CA certificate caCertPEM := generateTestCACert(t) - err := os.WriteFile(caCertPath, caCertPEM, 0600) + err := os.WriteFile(caCertPath, caCertPEM, 0o600) require.NoError(t, err) // Both insecure and CA cert can be set together - client, err := buildHTTPClient(true, caCertPath, "", "", "") + client, err := buildHTTPClient(true, caCertPath, "", "") require.NoError(t, err) require.NotNil(t, client) diff --git a/taskfile/reader.go b/taskfile/reader.go index 44c45906be..83fcecddef 100644 --- a/taskfile/reader.go +++ b/taskfile/reader.go @@ -48,7 +48,6 @@ type ( caCert string cert string certKey string - certKeyPass string debugFunc DebugFunc promptFunc PromptFunc promptMutex sync.Mutex @@ -225,19 +224,6 @@ func (o *readerCertKeyOption) ApplyToReader(r *Reader) { r.certKey = o.certKey } -// WithReaderCertKeyPass sets the passphrase for the client certificate key. -func WithReaderCertKeyPass(certKeyPass string) ReaderOption { - return &readerCertKeyPassOption{certKeyPass: certKeyPass} -} - -type readerCertKeyPassOption struct { - certKeyPass string -} - -func (o *readerCertKeyPassOption) ApplyToReader(r *Reader) { - r.certKeyPass = o.certKeyPass -} - // 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 @@ -328,7 +314,6 @@ func (r *Reader) include(ctx context.Context, node Node) error { WithCACert(r.caCert), WithCert(r.cert), WithCertKey(r.certKey), - WithCertKeyPass(r.certKeyPass), ) if err != nil { if include.Optional { diff --git a/taskrc/ast/taskrc.go b/taskrc/ast/taskrc.go index fd1db957ba..27f976f4a0 100644 --- a/taskrc/ast/taskrc.go +++ b/taskrc/ast/taskrc.go @@ -24,7 +24,6 @@ type Remote struct { CACert *string `yaml:"cacert"` Cert *string `yaml:"cert"` CertKey *string `yaml:"cert-key"` - CertKeyPass *string `yaml:"cert-key-pass"` } // Merge combines the current TaskRC with another TaskRC, prioritizing non-nil fields from the other TaskRC. @@ -49,7 +48,6 @@ func (t *TaskRC) Merge(other *TaskRC) { 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.Remote.CertKeyPass = cmp.Or(other.Remote.CertKeyPass, t.Remote.CertKeyPass) 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 8665006614..4e25b2e6ee 100644 --- a/website/src/docs/experiments/remote-taskfiles.md +++ b/website/src/docs/experiments/remote-taskfiles.md @@ -279,16 +279,17 @@ task --taskfile https://secure.example.com/Taskfile.yml \ --cert-key /path/to/client.key ``` -If your private key is encrypted with a passphrase, you can provide it using -the `--cert-key-pass` flag: +::: warning + +Encrypted private keys are not currently supported. If your key is encrypted, +you must decrypt it first: ```shell -task --taskfile https://secure.example.com/Taskfile.yml \ - --cert /path/to/client.crt \ - --cert-key /path/to/client.key \ - --cert-key-pass "your-passphrase" +openssl rsa -in encrypted.key -out decrypted.key ``` +::: + These options can also be configured in the [configuration file](#configuration). ## Caching & Running Offline @@ -339,7 +340,6 @@ remote: cacert: "" cert: "" cert-key: "" - cert-key-pass: "" ``` #### `insecure` @@ -421,15 +421,3 @@ remote: remote: cert-key: "/path/to/client.key" ``` - -#### `cert-key-pass` - -- **Type**: `string` -- **Default**: `""` -- **Description**: Passphrase for the client certificate private key (if - encrypted) - -```yaml -remote: - cert-key-pass: "your-passphrase" -``` From 05f4b080a5cba7dd57f52d4980c9925b37dbc81f Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 29 Nov 2025 19:40:01 +0100 Subject: [PATCH 4/5] refactor --- taskfile/node_http.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/taskfile/node_http.go b/taskfile/node_http.go index 72a2e273a7..75b8ece3bf 100644 --- a/taskfile/node_http.go +++ b/taskfile/node_http.go @@ -38,7 +38,7 @@ func buildHTTPClient(insecure bool, caCert, cert, certKey string) (*http.Client, } tlsConfig := &tls.Config{ - InsecureSkipVerify: insecure, //nolint:gosec + InsecureSkipVerify: insecure, } // Load custom CA certificate if provided @@ -84,8 +84,7 @@ func NewHTTPNode( if url.Scheme == "http" && !insecure { return nil, &errors.TaskfileNotSecureError{URI: url.Redacted()} } - - // Build HTTP client with TLS configuration from node options + client, err := buildHTTPClient(insecure, base.caCert, base.cert, base.certKey) if err != nil { return nil, err From db414ae14824a89f286aeca90a1bb42d753e6d89 Mon Sep 17 00:00:00 2001 From: Valentin Maerten Date: Sat, 29 Nov 2025 19:42:27 +0100 Subject: [PATCH 5/5] refactor --- taskfile/node_http.go | 2 +- taskfile/node_http_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/taskfile/node_http.go b/taskfile/node_http.go index 75b8ece3bf..914b8b9a51 100644 --- a/taskfile/node_http.go +++ b/taskfile/node_http.go @@ -84,7 +84,7 @@ 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 diff --git a/taskfile/node_http_test.go b/taskfile/node_http_test.go index cfa273294b..359ec798bf 100644 --- a/taskfile/node_http_test.go +++ b/taskfile/node_http_test.go @@ -221,7 +221,7 @@ func generateTestCertAndKey(t *testing.T) (certPEM, keyPEM []byte) { template := x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{ - Organization: []string{"Test Org"}, + Organization: []string{"Task Org"}, }, NotBefore: time.Now(), NotAfter: time.Now().Add(time.Hour),