mirror of
https://github.com/go-task/task.git
synced 2026-06-11 09:51:50 +00:00
feat: support self-signed certificates for remote taskfiles (#2537)
This commit is contained in:
@@ -8,6 +8,9 @@
|
|||||||
- Included Taskfiles with `silent: true` now properly propagate silence to their
|
- Included Taskfiles with `silent: true` now properly propagate silence to their
|
||||||
tasks, while still allowing individual tasks to override with `silent: false`
|
tasks, while still allowing individual tasks to override with `silent: false`
|
||||||
(#2640, #1319 by @trulede).
|
(#2640, #1319 by @trulede).
|
||||||
|
- Added TLS certificate options for Remote Taskfiles: use `--cacert` for
|
||||||
|
self-signed certificates and `--cert`/`--cert-key` for mTLS authentication
|
||||||
|
(#2537, #2242 by @vmaerten).
|
||||||
|
|
||||||
## v3.47.0 - 2026-01-24
|
## v3.47.0 - 2026-01-24
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ function _task()
|
|||||||
_filedir -d
|
_filedir -d
|
||||||
return $?
|
return $?
|
||||||
;;
|
;;
|
||||||
|
--cacert|--cert|--cert-key)
|
||||||
|
_filedir
|
||||||
|
return $?
|
||||||
|
;;
|
||||||
-t|--taskfile)
|
-t|--taskfile)
|
||||||
_filedir yaml || return $?
|
_filedir yaml || return $?
|
||||||
_filedir yml
|
_filedir yml
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_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 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 expiry -d 'cache expiry duration'
|
||||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l remote-cache-dir -d 'directory to cache remote Taskfiles' -xa "(__fish_complete_directories)"
|
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l remote-cache-dir -d 'directory to cache remote Taskfiles' -xa "(__fish_complete_directories)"
|
||||||
|
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
|
# RemoteTaskfiles experiment - Operations
|
||||||
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l download -d 'download remote Taskfile'
|
complete -c $GO_TASK_PROGNAME -n "__task_is_experiment_enabled REMOTE_TASKFILES" -l download -d 'download remote Taskfile'
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ Register-ArgumentCompleter -CommandName task -ScriptBlock {
|
|||||||
$completions += [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'download timeout')
|
$completions += [CompletionResult]::new('--timeout', '--timeout', [CompletionResultType]::ParameterName, 'download timeout')
|
||||||
$completions += [CompletionResult]::new('--expiry', '--expiry', [CompletionResultType]::ParameterName, 'cache expiry')
|
$completions += [CompletionResult]::new('--expiry', '--expiry', [CompletionResultType]::ParameterName, 'cache expiry')
|
||||||
$completions += [CompletionResult]::new('--remote-cache-dir', '--remote-cache-dir', [CompletionResultType]::ParameterName, 'cache directory')
|
$completions += [CompletionResult]::new('--remote-cache-dir', '--remote-cache-dir', [CompletionResultType]::ParameterName, 'cache directory')
|
||||||
|
$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
|
# Operations
|
||||||
$completions += [CompletionResult]::new('--download', '--download', [CompletionResultType]::ParameterName, 'download remote Taskfile')
|
$completions += [CompletionResult]::new('--download', '--download', [CompletionResultType]::ParameterName, 'download remote Taskfile')
|
||||||
$completions += [CompletionResult]::new('--clear-cache', '--clear-cache', [CompletionResultType]::ParameterName, 'clear cache')
|
$completions += [CompletionResult]::new('--clear-cache', '--clear-cache', [CompletionResultType]::ParameterName, 'clear cache')
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ _task() {
|
|||||||
'(--timeout)--timeout[timeout for remote Taskfile downloads]:duration: '
|
'(--timeout)--timeout[timeout for remote Taskfile downloads]:duration: '
|
||||||
'(--expiry)--expiry[cache expiry duration]:duration: '
|
'(--expiry)--expiry[cache expiry duration]:duration: '
|
||||||
'(--remote-cache-dir)--remote-cache-dir[directory to cache remote Taskfiles]:cache dir:_dirs'
|
'(--remote-cache-dir)--remote-cache-dir[directory to cache remote Taskfiles]:cache dir:_dirs'
|
||||||
|
'(--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
|
fi
|
||||||
|
|
||||||
|
|||||||
42
executor.go
42
executor.go
@@ -38,6 +38,9 @@ type (
|
|||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
CacheExpiryDuration time.Duration
|
CacheExpiryDuration time.Duration
|
||||||
RemoteCacheDir string
|
RemoteCacheDir string
|
||||||
|
CACert string
|
||||||
|
Cert string
|
||||||
|
CertKey string
|
||||||
Watch bool
|
Watch bool
|
||||||
Verbose bool
|
Verbose bool
|
||||||
Silent bool
|
Silent bool
|
||||||
@@ -287,6 +290,45 @@ func (o *remoteCacheDirOption) ApplyToExecutor(e *Executor) {
|
|||||||
e.RemoteCacheDir = o.dir
|
e.RemoteCacheDir = o.dir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// 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
|
// for changes to the fingerprint of the tasks that are run. When changes are
|
||||||
// detected, a new task run is triggered.
|
// detected, a new task run is triggered.
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ var (
|
|||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
CacheExpiryDuration time.Duration
|
CacheExpiryDuration time.Duration
|
||||||
RemoteCacheDir string
|
RemoteCacheDir string
|
||||||
|
CACert string
|
||||||
|
Cert string
|
||||||
|
CertKey string
|
||||||
Interactive bool
|
Interactive bool
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -168,6 +171,9 @@ func init() {
|
|||||||
pflag.BoolVar(&ClearCache, "clear-cache", false, "Clear the remote cache.")
|
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.DurationVar(&CacheExpiryDuration, "expiry", getConfig(config, func() *time.Duration { return config.Remote.CacheExpiry }, 0), "Expiry duration for cached remote Taskfiles.")
|
||||||
pflag.StringVar(&RemoteCacheDir, "remote-cache-dir", getConfig(config, func() *string { return config.Remote.CacheDir }, env.GetTaskEnv("REMOTE_DIR")), "Directory to cache remote Taskfiles.")
|
pflag.StringVar(&RemoteCacheDir, "remote-cache-dir", getConfig(config, func() *string { return config.Remote.CacheDir }, env.GetTaskEnv("REMOTE_DIR")), "Directory to cache 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()
|
pflag.Parse()
|
||||||
|
|
||||||
@@ -236,6 +242,11 @@ func Validate() error {
|
|||||||
return errors.New("task: --nested only applies to --json with --list or --list-all")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +289,9 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) {
|
|||||||
task.WithTimeout(Timeout),
|
task.WithTimeout(Timeout),
|
||||||
task.WithCacheExpiryDuration(CacheExpiryDuration),
|
task.WithCacheExpiryDuration(CacheExpiryDuration),
|
||||||
task.WithRemoteCacheDir(RemoteCacheDir),
|
task.WithRemoteCacheDir(RemoteCacheDir),
|
||||||
|
task.WithCACert(CACert),
|
||||||
|
task.WithCert(Cert),
|
||||||
|
task.WithCertKey(CertKey),
|
||||||
task.WithWatch(Watch),
|
task.WithWatch(Watch),
|
||||||
task.WithVerbose(Verbose),
|
task.WithVerbose(Verbose),
|
||||||
task.WithSilent(Silent),
|
task.WithSilent(Silent),
|
||||||
|
|||||||
9
setup.go
9
setup.go
@@ -55,7 +55,11 @@ func (e *Executor) Setup() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *Executor) getRootNode() (taskfile.Node, 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) {
|
if os.IsNotExist(err) {
|
||||||
return nil, errors.TaskfileNotFoundError{
|
return nil, errors.TaskfileNotFoundError{
|
||||||
URI: fsext.DefaultDir(e.Entrypoint, e.Dir),
|
URI: fsext.DefaultDir(e.Entrypoint, e.Dir),
|
||||||
@@ -87,6 +91,9 @@ func (e *Executor) readTaskfile(node taskfile.Node) error {
|
|||||||
taskfile.WithTrustedHosts(e.TrustedHosts),
|
taskfile.WithTrustedHosts(e.TrustedHosts),
|
||||||
taskfile.WithTempDir(e.TempDir.Remote),
|
taskfile.WithTempDir(e.TempDir.Remote),
|
||||||
taskfile.WithCacheExpiryDuration(e.CacheExpiryDuration),
|
taskfile.WithCacheExpiryDuration(e.CacheExpiryDuration),
|
||||||
|
taskfile.WithReaderCACert(e.CACert),
|
||||||
|
taskfile.WithReaderCert(e.Cert),
|
||||||
|
taskfile.WithReaderCertKey(e.CertKey),
|
||||||
taskfile.WithDebugFunc(debugFunc),
|
taskfile.WithDebugFunc(debugFunc),
|
||||||
taskfile.WithPromptFunc(promptFunc),
|
taskfile.WithPromptFunc(promptFunc),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,13 +34,14 @@ func NewRootNode(
|
|||||||
dir string,
|
dir string,
|
||||||
insecure bool,
|
insecure bool,
|
||||||
timeout time.Duration,
|
timeout time.Duration,
|
||||||
|
opts ...NodeOption,
|
||||||
) (Node, error) {
|
) (Node, error) {
|
||||||
dir = fsext.DefaultDir(entrypoint, dir)
|
dir = fsext.DefaultDir(entrypoint, dir)
|
||||||
// If the entrypoint is "-", we read from stdin
|
// If the entrypoint is "-", we read from stdin
|
||||||
if entrypoint == "-" {
|
if entrypoint == "-" {
|
||||||
return NewStdinNode(dir)
|
return NewStdinNode(dir)
|
||||||
}
|
}
|
||||||
return NewNode(entrypoint, dir, insecure)
|
return NewNode(entrypoint, dir, insecure, opts...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNode(
|
func NewNode(
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ type (
|
|||||||
parent Node
|
parent Node
|
||||||
dir string
|
dir string
|
||||||
checksum 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 {
|
func (node *baseNode) Verify(checksum string) bool {
|
||||||
return node.checksum == "" || node.checksum == checksum
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package taskfile
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -17,7 +20,54 @@ import (
|
|||||||
// An HTTPNode is a node that reads a Taskfile from a remote location via HTTP.
|
// An HTTPNode is a node that reads a Taskfile from a remote location via HTTP.
|
||||||
type HTTPNode struct {
|
type HTTPNode struct {
|
||||||
*baseNode
|
*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(
|
func NewHTTPNode(
|
||||||
@@ -34,9 +84,16 @@ func NewHTTPNode(
|
|||||||
if url.Scheme == "http" && !insecure {
|
if url.Scheme == "http" && !insecure {
|
||||||
return nil, &errors.TaskfileNotSecureError{URI: url.Redacted()}
|
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{
|
return &HTTPNode{
|
||||||
baseNode: base,
|
baseNode: base,
|
||||||
url: url,
|
url: url,
|
||||||
|
client: client,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +106,7 @@ func (node *HTTPNode) Read() ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (node *HTTPNode) ReadContext(ctx context.Context) ([]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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -58,7 +115,7 @@ func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) {
|
|||||||
return nil, errors.TaskfileFetchFailedError{URI: node.Location()}
|
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 err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
package taskfile
|
package taskfile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -47,3 +58,227 @@ func TestHTTPNode_CacheKey(t *testing.T) {
|
|||||||
assert.Equal(t, tt.expectedKey, key)
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ type (
|
|||||||
trustedHosts []string
|
trustedHosts []string
|
||||||
tempDir string
|
tempDir string
|
||||||
cacheExpiryDuration time.Duration
|
cacheExpiryDuration time.Duration
|
||||||
|
caCert string
|
||||||
|
cert string
|
||||||
|
certKey string
|
||||||
debugFunc DebugFunc
|
debugFunc DebugFunc
|
||||||
promptFunc PromptFunc
|
promptFunc PromptFunc
|
||||||
promptMutex sync.Mutex
|
promptMutex sync.Mutex
|
||||||
@@ -199,6 +202,45 @@ func (o *promptFuncOption) ApplyToReader(r *Reader) {
|
|||||||
r.promptFunc = o.promptFunc
|
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
|
// Read will read the Taskfile defined by the [Reader]'s [Node] and recurse
|
||||||
// through any [ast.Includes] it finds, reading each included Taskfile and
|
// 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
|
// building an [ast.TaskfileGraph] as it goes. If any errors occur, they will be
|
||||||
@@ -314,6 +356,9 @@ func (r *Reader) include(ctx context.Context, node Node) error {
|
|||||||
includeNode, err := NewNode(entrypoint, include.Dir, r.insecure,
|
includeNode, err := NewNode(entrypoint, include.Dir, r.insecure,
|
||||||
WithParent(node),
|
WithParent(node),
|
||||||
WithChecksum(include.Checksum),
|
WithChecksum(include.Checksum),
|
||||||
|
WithCACert(r.caCert),
|
||||||
|
WithCert(r.cert),
|
||||||
|
WithCertKey(r.certKey),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if include.Optional {
|
if include.Optional {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ var (
|
|||||||
// at the given URL with any of the default Taskfile files names. If any of
|
// 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
|
// these match a file, the first matching path will be returned. If no files are
|
||||||
// found, an error will be returned.
|
// 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
|
// Create a new HEAD request for the given URL to check if the resource exists
|
||||||
req, err := http.NewRequestWithContext(ctx, "HEAD", u.String(), nil)
|
req, err := http.NewRequestWithContext(ctx, "HEAD", u.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -46,7 +46,7 @@ func RemoteExists(ctx context.Context, u url.URL) (*url.URL, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Request the given URL
|
// Request the given URL
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil, fmt.Errorf("checking remote file: %w", ctx.Err())
|
return nil, fmt.Errorf("checking remote file: %w", ctx.Err())
|
||||||
@@ -78,7 +78,7 @@ func RemoteExists(ctx context.Context, u url.URL) (*url.URL, error) {
|
|||||||
req.URL = alt
|
req.URL = alt
|
||||||
|
|
||||||
// Try the alternative URL
|
// Try the alternative URL
|
||||||
resp, err = http.DefaultClient.Do(req)
|
resp, err = client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()}
|
return nil, errors.TaskfileFetchFailedError{URI: u.Redacted()}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ type Remote struct {
|
|||||||
CacheExpiry *time.Duration `yaml:"cache-expiry"`
|
CacheExpiry *time.Duration `yaml:"cache-expiry"`
|
||||||
CacheDir *string `yaml:"cache-dir"`
|
CacheDir *string `yaml:"cache-dir"`
|
||||||
TrustedHosts []string `yaml:"trusted-hosts"`
|
TrustedHosts []string `yaml:"trusted-hosts"`
|
||||||
|
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.
|
// Merge combines the current TaskRC with another TaskRC, prioritizing non-nil fields from the other TaskRC.
|
||||||
@@ -55,6 +58,9 @@ func (t *TaskRC) Merge(other *TaskRC) {
|
|||||||
slices.Sort(merged)
|
slices.Sort(merged)
|
||||||
t.Remote.TrustedHosts = slices.Compact(merged)
|
t.Remote.TrustedHosts = slices.Compact(merged)
|
||||||
}
|
}
|
||||||
|
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.Verbose = cmp.Or(other.Verbose, t.Verbose)
|
||||||
t.Color = cmp.Or(other.Color, t.Color)
|
t.Color = cmp.Or(other.Color, t.Color)
|
||||||
|
|||||||
@@ -263,6 +263,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
|
protected by TLS are vulnerable to man-in-the-middle attacks and should be
|
||||||
avoided unless you know what you are doing.
|
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
|
## Caching & Running Offline
|
||||||
|
|
||||||
Whenever you run a remote Taskfile, the latest copy will be downloaded from the
|
Whenever you run a remote Taskfile, the latest copy will be downloaded from the
|
||||||
@@ -313,6 +345,9 @@ remote:
|
|||||||
trusted-hosts:
|
trusted-hosts:
|
||||||
- github.com
|
- github.com
|
||||||
- gitlab.com
|
- gitlab.com
|
||||||
|
cacert: ""
|
||||||
|
cert: ""
|
||||||
|
cert-key: ""
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `insecure`
|
#### `insecure`
|
||||||
@@ -410,3 +445,36 @@ task --trusted-hosts github.com,gitlab.com -t https://github.com/user/repo.git//
|
|||||||
# Trust a host with a specific port
|
# Trust a host with a specific port
|
||||||
task --trusted-hosts example.com:8080 -t https://example.com:8080/Taskfile.yml
|
task --trusted-hosts example.com:8080 -t https://example.com:8080/Taskfile.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### `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"
|
||||||
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user