Skip to content

Commit 723d9f0

Browse files
committed
feat: support self-signed certificates for remote taskfiles
1 parent eb285fa commit 723d9f0

File tree

11 files changed

+615
-11
lines changed

11 files changed

+615
-11
lines changed

executor.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ type (
3636
Offline bool
3737
Timeout time.Duration
3838
CacheExpiryDuration time.Duration
39+
CACert string
40+
Cert string
41+
CertKey string
42+
CertKeyPass string
3943
Watch bool
4044
Verbose bool
4145
Silent bool
@@ -253,6 +257,58 @@ func (o *cacheExpiryDurationOption) ApplyToExecutor(r *Executor) {
253257
r.CacheExpiryDuration = o.duration
254258
}
255259

260+
// WithCACert sets the path to a custom CA certificate for TLS connections.
261+
func WithCACert(caCert string) ExecutorOption {
262+
return &caCertOption{caCert: caCert}
263+
}
264+
265+
type caCertOption struct {
266+
caCert string
267+
}
268+
269+
func (o *caCertOption) ApplyToExecutor(e *Executor) {
270+
e.CACert = o.caCert
271+
}
272+
273+
// WithCert sets the path to a client certificate for TLS connections.
274+
func WithCert(cert string) ExecutorOption {
275+
return &certOption{cert: cert}
276+
}
277+
278+
type certOption struct {
279+
cert string
280+
}
281+
282+
func (o *certOption) ApplyToExecutor(e *Executor) {
283+
e.Cert = o.cert
284+
}
285+
286+
// WithCertKey sets the path to a client certificate key for TLS connections.
287+
func WithCertKey(certKey string) ExecutorOption {
288+
return &certKeyOption{certKey: certKey}
289+
}
290+
291+
type certKeyOption struct {
292+
certKey string
293+
}
294+
295+
func (o *certKeyOption) ApplyToExecutor(e *Executor) {
296+
e.CertKey = o.certKey
297+
}
298+
299+
// WithCertKeyPass sets the passphrase for the client certificate key.
300+
func WithCertKeyPass(certKeyPass string) ExecutorOption {
301+
return &certKeyPassOption{certKeyPass: certKeyPass}
302+
}
303+
304+
type certKeyPassOption struct {
305+
certKeyPass string
306+
}
307+
308+
func (o *certKeyPassOption) ApplyToExecutor(e *Executor) {
309+
e.CertKeyPass = o.certKeyPass
310+
}
311+
256312
// WithWatch tells the [Executor] to keep running in the background and watch
257313
// for changes to the fingerprint of the tasks that are run. When changes are
258314
// detected, a new task run is triggered.

internal/flags/flags.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ var (
7676
ClearCache bool
7777
Timeout time.Duration
7878
CacheExpiryDuration time.Duration
79+
CACert string
80+
Cert string
81+
CertKey string
82+
CertKeyPass string
7983
)
8084

8185
func init() {
@@ -155,6 +159,10 @@ func init() {
155159
pflag.DurationVar(&Timeout, "timeout", getConfig(config, func() *time.Duration { return config.Remote.Timeout }, time.Second*10), "Timeout for downloading remote Taskfiles.")
156160
pflag.BoolVar(&ClearCache, "clear-cache", false, "Clear the remote cache.")
157161
pflag.DurationVar(&CacheExpiryDuration, "expiry", getConfig(config, func() *time.Duration { return config.Remote.CacheExpiry }, 0), "Expiry duration for cached remote Taskfiles.")
162+
pflag.StringVar(&CACert, "cacert", getConfig(config, func() *string { return config.Remote.CACert }, ""), "Path to a custom CA certificate for HTTPS connections.")
163+
pflag.StringVar(&Cert, "cert", getConfig(config, func() *string { return config.Remote.Cert }, ""), "Path to a client certificate for HTTPS connections.")
164+
pflag.StringVar(&CertKey, "cert-key", getConfig(config, func() *string { return config.Remote.CertKey }, ""), "Path to a client certificate key for HTTPS connections.")
165+
pflag.StringVar(&CertKeyPass, "cert-key-pass", getConfig(config, func() *string { return config.Remote.CertKeyPass }, ""), "Passphrase for the client certificate key.")
158166
}
159167
pflag.Parse()
160168
}
@@ -200,6 +208,15 @@ func Validate() error {
200208
return errors.New("task: --nested only applies to --json with --list or --list-all")
201209
}
202210

211+
// Validate certificate flags
212+
if (Cert != "" && CertKey == "") || (Cert == "" && CertKey != "") {
213+
return errors.New("task: --cert and --cert-key must be provided together")
214+
}
215+
216+
if CertKeyPass != "" && Cert == "" {
217+
return errors.New("task: --cert-key-pass requires --cert and --cert-key")
218+
}
219+
203220
return nil
204221
}
205222

@@ -240,6 +257,10 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) {
240257
task.WithOffline(Offline),
241258
task.WithTimeout(Timeout),
242259
task.WithCacheExpiryDuration(CacheExpiryDuration),
260+
task.WithCACert(CACert),
261+
task.WithCert(Cert),
262+
task.WithCertKey(CertKey),
263+
task.WithCertKeyPass(CertKeyPass),
243264
task.WithWatch(Watch),
244265
task.WithVerbose(Verbose),
245266
task.WithSilent(Silent),

setup.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,12 @@ func (e *Executor) Setup() error {
5656
}
5757

5858
func (e *Executor) getRootNode() (taskfile.Node, error) {
59-
node, err := taskfile.NewRootNode(e.Entrypoint, e.Dir, e.Insecure, e.Timeout)
59+
node, err := taskfile.NewRootNode(e.Entrypoint, e.Dir, e.Insecure, e.Timeout,
60+
taskfile.WithCACert(e.CACert),
61+
taskfile.WithCert(e.Cert),
62+
taskfile.WithCertKey(e.CertKey),
63+
taskfile.WithCertKeyPass(e.CertKeyPass),
64+
)
6065
if os.IsNotExist(err) {
6166
return nil, errors.TaskfileNotFoundError{
6267
URI: fsext.DefaultDir(e.Entrypoint, e.Dir),
@@ -86,6 +91,10 @@ func (e *Executor) readTaskfile(node taskfile.Node) error {
8691
taskfile.WithOffline(e.Offline),
8792
taskfile.WithTempDir(e.TempDir.Remote),
8893
taskfile.WithCacheExpiryDuration(e.CacheExpiryDuration),
94+
taskfile.WithReaderCACert(e.CACert),
95+
taskfile.WithReaderCert(e.Cert),
96+
taskfile.WithReaderCertKey(e.CertKey),
97+
taskfile.WithReaderCertKeyPass(e.CertKeyPass),
8998
taskfile.WithDebugFunc(debugFunc),
9099
taskfile.WithPromptFunc(promptFunc),
91100
)

taskfile/node.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,14 @@ func NewRootNode(
3434
dir string,
3535
insecure bool,
3636
timeout time.Duration,
37+
opts ...NodeOption,
3738
) (Node, error) {
3839
dir = fsext.DefaultDir(entrypoint, dir)
3940
// If the entrypoint is "-", we read from stdin
4041
if entrypoint == "-" {
4142
return NewStdinNode(dir)
4243
}
43-
return NewNode(entrypoint, dir, insecure)
44+
return NewNode(entrypoint, dir, insecure, opts...)
4445
}
4546

4647
func NewNode(

taskfile/node_base.go

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ type (
77
// designed to be embedded in other node types so that this boilerplate code
88
// does not need to be repeated.
99
baseNode struct {
10-
parent Node
11-
dir string
12-
checksum string
10+
parent Node
11+
dir string
12+
checksum string
13+
caCert string
14+
cert string
15+
certKey string
16+
certKeyPass string
1317
}
1418
)
1519

@@ -54,3 +58,27 @@ func (node *baseNode) Checksum() string {
5458
func (node *baseNode) Verify(checksum string) bool {
5559
return node.checksum == "" || node.checksum == checksum
5660
}
61+
62+
func WithCACert(caCert string) NodeOption {
63+
return func(node *baseNode) {
64+
node.caCert = caCert
65+
}
66+
}
67+
68+
func WithCert(cert string) NodeOption {
69+
return func(node *baseNode) {
70+
node.cert = cert
71+
}
72+
}
73+
74+
func WithCertKey(certKey string) NodeOption {
75+
return func(node *baseNode) {
76+
node.certKey = certKey
77+
}
78+
}
79+
80+
func WithCertKeyPass(certKeyPass string) NodeOption {
81+
return func(node *baseNode) {
82+
node.certKeyPass = certKeyPass
83+
}
84+
}

taskfile/node_http.go

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ package taskfile
22

33
import (
44
"context"
5+
"crypto/tls"
6+
"crypto/x509"
57
"fmt"
68
"io"
79
"net/http"
810
"net/url"
11+
"os"
912
"path/filepath"
1013
"strings"
1114

@@ -17,7 +20,102 @@ import (
1720
// An HTTPNode is a node that reads a Taskfile from a remote location via HTTP.
1821
type HTTPNode struct {
1922
*baseNode
20-
url *url.URL // stores url pointing actual remote file. (e.g. with Taskfile.yml)
23+
url *url.URL // stores url pointing actual remote file. (e.g. with Taskfile.yml)
24+
client *http.Client // HTTP client with optional TLS configuration
25+
}
26+
27+
// buildHTTPClient creates an HTTP client with optional TLS configuration.
28+
// If no certificate options are provided, it returns http.DefaultClient.
29+
func buildHTTPClient(insecure bool, caCert, cert, certKey, certKeyPass string) (*http.Client, error) {
30+
// Validate that cert and certKey are provided together
31+
if (cert != "" && certKey == "") || (cert == "" && certKey != "") {
32+
return nil, fmt.Errorf("both --cert and --cert-key must be provided together")
33+
}
34+
35+
// If no TLS customization is needed, return the default client
36+
if !insecure && caCert == "" && cert == "" {
37+
return http.DefaultClient, nil
38+
}
39+
40+
tlsConfig := &tls.Config{
41+
InsecureSkipVerify: insecure, //nolint:gosec
42+
}
43+
44+
// Load custom CA certificate if provided
45+
if caCert != "" {
46+
caCertData, err := os.ReadFile(caCert)
47+
if err != nil {
48+
return nil, fmt.Errorf("failed to read CA certificate: %w", err)
49+
}
50+
caCertPool := x509.NewCertPool()
51+
if !caCertPool.AppendCertsFromPEM(caCertData) {
52+
return nil, fmt.Errorf("failed to parse CA certificate")
53+
}
54+
tlsConfig.RootCAs = caCertPool
55+
}
56+
57+
// Load client certificate and key if provided
58+
if cert != "" && certKey != "" {
59+
var clientCert tls.Certificate
60+
var err error
61+
62+
if certKeyPass != "" {
63+
// Load encrypted private key
64+
clientCert, err = loadCertWithEncryptedKey(cert, certKey, certKeyPass)
65+
} else {
66+
clientCert, err = tls.LoadX509KeyPair(cert, certKey)
67+
}
68+
if err != nil {
69+
return nil, fmt.Errorf("failed to load client certificate: %w", err)
70+
}
71+
tlsConfig.Certificates = []tls.Certificate{clientCert}
72+
}
73+
74+
return &http.Client{
75+
Transport: &http.Transport{
76+
TLSClientConfig: tlsConfig,
77+
},
78+
}, nil
79+
}
80+
81+
// loadCertWithEncryptedKey loads a certificate with an encrypted private key.
82+
func loadCertWithEncryptedKey(certFile, keyFile, passphrase string) (tls.Certificate, error) {
83+
certPEM, err := os.ReadFile(certFile)
84+
if err != nil {
85+
return tls.Certificate{}, fmt.Errorf("failed to read certificate file: %w", err)
86+
}
87+
88+
keyPEM, err := os.ReadFile(keyFile)
89+
if err != nil {
90+
return tls.Certificate{}, fmt.Errorf("failed to read key file: %w", err)
91+
}
92+
93+
// Try to decrypt the private key
94+
decryptedKey, err := decryptPEMKey(keyPEM, passphrase)
95+
if err != nil {
96+
return tls.Certificate{}, fmt.Errorf("failed to decrypt private key: %w", err)
97+
}
98+
99+
return tls.X509KeyPair(certPEM, decryptedKey)
100+
}
101+
102+
// decryptPEMKey attempts to decrypt a PEM-encoded private key.
103+
func decryptPEMKey(keyPEM []byte, passphrase string) ([]byte, error) {
104+
// For PKCS#8 encrypted keys, we need to parse and decrypt them
105+
// The standard library doesn't directly support encrypted PKCS#8,
106+
// so we try to parse it as-is first (in case it's not actually encrypted)
107+
// For now, we support unencrypted keys and return an error for encrypted ones
108+
// that require external libraries to decrypt.
109+
110+
// Try to parse as unencrypted first
111+
_, err := tls.X509KeyPair([]byte("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"), keyPEM)
112+
if err == nil {
113+
return keyPEM, nil
114+
}
115+
116+
// TODO: Add support for encrypted PKCS#8 keys using x/crypto/pkcs8
117+
// This would require adding a dependency on golang.org/x/crypto
118+
return nil, fmt.Errorf("encrypted private keys require the key to be decrypted externally, or use an unencrypted key")
21119
}
22120

23121
func NewHTTPNode(
@@ -34,9 +132,17 @@ func NewHTTPNode(
34132
if url.Scheme == "http" && !insecure {
35133
return nil, &errors.TaskfileNotSecureError{URI: url.Redacted()}
36134
}
135+
136+
// Build HTTP client with TLS configuration from node options
137+
client, err := buildHTTPClient(insecure, base.caCert, base.cert, base.certKey, base.certKeyPass)
138+
if err != nil {
139+
return nil, err
140+
}
141+
37142
return &HTTPNode{
38143
baseNode: base,
39144
url: url,
145+
client: client,
40146
}, nil
41147
}
42148

@@ -49,7 +155,7 @@ func (node *HTTPNode) Read() ([]byte, error) {
49155
}
50156

51157
func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) {
52-
url, err := RemoteExists(ctx, *node.url)
158+
url, err := RemoteExists(ctx, *node.url, node.client)
53159
if err != nil {
54160
return nil, err
55161
}
@@ -58,7 +164,7 @@ func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) {
58164
return nil, errors.TaskfileFetchFailedError{URI: node.Location()}
59165
}
60166

61-
resp, err := http.DefaultClient.Do(req.WithContext(ctx))
167+
resp, err := node.client.Do(req.WithContext(ctx))
62168
if err != nil {
63169
if ctx.Err() != nil {
64170
return nil, err

0 commit comments

Comments
 (0)