mirror of
https://github.com/go-task/task.git
synced 2026-10-23 11:48:55 +03:00
feat(remote): add remote.headers to use HTTP Header (#2976)
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -6,12 +6,22 @@
|
||||
|
||||
- Added versioned Homebrew casks (`go-task@<major>.<minor>`) to install a
|
||||
specific minor version of Task (#3023 by @vmaerten).
|
||||
- Added a `remote.headers` config option to send HTTP headers when downloading a
|
||||
remote Taskfile, configured per host. Header values support templating
|
||||
functions, e.g. `{{env "GITLAB_TOKEN"}}`. This keeps the credential out of the
|
||||
include URL, where it would leak into error messages and the confirmation
|
||||
prompt (#2329 by @vmaerten).
|
||||
|
||||
### 🐛 Fixes
|
||||
|
||||
- Fixed a bug on Windows where watching source paths did not work as expected
|
||||
(#2863, #3028 by @pd93).
|
||||
|
||||
- Fixed a remote Taskfile whose server refuses the credentials being reported as
|
||||
a missing Taskfile. A `401` now stops the search and reports the status code,
|
||||
instead of retrying every default Taskfile name and concluding that no
|
||||
Taskfile exists (#2329 by @vmaerten).
|
||||
|
||||
### 📦 Package API
|
||||
|
||||
- Bumped the minimum Go version to 1.26. Task follows Go's two-latest support
|
||||
|
||||
16
executor.go
16
executor.go
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/go-task/task/v3/internal/logger"
|
||||
"github.com/go-task/task/v3/internal/output"
|
||||
"github.com/go-task/task/v3/internal/sort"
|
||||
"github.com/go-task/task/v3/taskfile"
|
||||
"github.com/go-task/task/v3/taskfile/ast"
|
||||
)
|
||||
|
||||
@@ -36,6 +37,7 @@ type (
|
||||
Download bool
|
||||
Offline bool
|
||||
TrustedHosts []string
|
||||
RemoteHeaders taskfile.HeadersByHost
|
||||
Timeout time.Duration
|
||||
CacheExpiryDuration time.Duration
|
||||
RemoteCacheDir string
|
||||
@@ -277,6 +279,20 @@ func (o *trustedHostsOption) ApplyToExecutor(e *Executor) {
|
||||
e.TrustedHosts = o.trustedHosts
|
||||
}
|
||||
|
||||
// WithRemoteHeaders configures the [Executor] with the HTTP headers to send when
|
||||
// fetching a remote Taskfile, keyed by host.
|
||||
func WithRemoteHeaders(remoteHeaders taskfile.HeadersByHost) ExecutorOption {
|
||||
return &remoteHeadersOption{remoteHeaders}
|
||||
}
|
||||
|
||||
type remoteHeadersOption struct {
|
||||
remoteHeaders taskfile.HeadersByHost
|
||||
}
|
||||
|
||||
func (o *remoteHeadersOption) ApplyToExecutor(e *Executor) {
|
||||
e.RemoteHeaders = o.remoteHeaders
|
||||
}
|
||||
|
||||
// WithTimeout sets the [Executor]'s timeout for fetching remote taskfiles. By
|
||||
// default, the timeout is set to 10 seconds.
|
||||
func WithTimeout(timeout time.Duration) ExecutorOption {
|
||||
|
||||
2
go.mod
2
go.mod
@@ -28,6 +28,7 @@ require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/zeebo/xxh3 v1.1.0
|
||||
go.yaml.in/yaml/v3 v3.0.4
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/sync v0.23.0
|
||||
golang.org/x/term v0.45.0
|
||||
mvdan.cc/sh/v3 v3.14.1
|
||||
@@ -121,7 +122,6 @@ require (
|
||||
go.opentelemetry.io/otel/trace v1.45.0 // indirect
|
||||
golang.org/x/crypto v0.56.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260718201538-764159d718ef // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/oauth2 v0.37.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/go-task/task/v3/experiments"
|
||||
"github.com/go-task/task/v3/internal/env"
|
||||
"github.com/go-task/task/v3/internal/sort"
|
||||
"github.com/go-task/task/v3/taskfile"
|
||||
"github.com/go-task/task/v3/taskfile/ast"
|
||||
"github.com/go-task/task/v3/taskrc"
|
||||
taskrcast "github.com/go-task/task/v3/taskrc/ast"
|
||||
@@ -79,6 +80,7 @@ var (
|
||||
Download bool
|
||||
Offline bool
|
||||
TrustedHosts []string
|
||||
RemoteHeaders taskfile.HeadersByHost
|
||||
ClearCache bool
|
||||
Timeout time.Duration
|
||||
CacheExpiryDuration time.Duration
|
||||
@@ -165,6 +167,8 @@ func init() {
|
||||
pflag.StringVar(&CACert, "cacert", getConfig(config, "REMOTE_CACERT", func() *string { return config.Remote.CACert }, ""), "Path to a custom CA certificate for HTTPS connections.")
|
||||
pflag.StringVar(&Cert, "cert", getConfig(config, "REMOTE_CERT", func() *string { return config.Remote.Cert }, ""), "Path to a client certificate for HTTPS connections.")
|
||||
pflag.StringVar(&CertKey, "cert-key", getConfig(config, "REMOTE_CERT_KEY", func() *string { return config.Remote.CertKey }, ""), "Path to a client certificate key for HTTPS connections.")
|
||||
// No flag: a token on the command line is visible to any process listing it.
|
||||
RemoteHeaders = remoteHeaders(config)
|
||||
|
||||
// Gentle force experiment will override the force flag and add a new force-all flag
|
||||
if experiments.GentleForce.Enabled() {
|
||||
@@ -285,6 +289,7 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) {
|
||||
task.WithDownload(Download),
|
||||
task.WithOffline(Offline),
|
||||
task.WithTrustedHosts(TrustedHosts),
|
||||
task.WithRemoteHeaders(RemoteHeaders),
|
||||
task.WithTimeout(Timeout),
|
||||
task.WithCacheExpiryDuration(CacheExpiryDuration),
|
||||
task.WithRemoteCacheDir(RemoteCacheDir),
|
||||
@@ -311,6 +316,18 @@ func (o *flagsOption) ApplyToExecutor(e *task.Executor) {
|
||||
)
|
||||
}
|
||||
|
||||
// The last entry for each host wins, matching config file merging.
|
||||
func remoteHeaders(config *taskrcast.TaskRC) taskfile.HeadersByHost {
|
||||
if config == nil || len(config.Remote.Headers) == 0 {
|
||||
return nil
|
||||
}
|
||||
byHost := make(taskfile.HeadersByHost, len(config.Remote.Headers))
|
||||
for _, entry := range config.Remote.Headers {
|
||||
byHost[entry.Host] = entry.Headers
|
||||
}
|
||||
return byHost
|
||||
}
|
||||
|
||||
// getConfig extracts a config value with priority: env var > taskrc config > fallback
|
||||
func getConfig[T any](config *taskrcast.TaskRC, envKey string, fieldFunc func() *T, fallback T) T {
|
||||
if envKey != "" {
|
||||
|
||||
2
setup.go
2
setup.go
@@ -58,6 +58,7 @@ func (e *Executor) getRootNode() (taskfile.Node, error) {
|
||||
taskfile.WithCACert(e.CACert),
|
||||
taskfile.WithCert(e.Cert),
|
||||
taskfile.WithCertKey(e.CertKey),
|
||||
taskfile.WithHeaders(e.RemoteHeaders),
|
||||
)
|
||||
if taskNotFoundError, ok := errors.AsType[errors.TaskfileNotFoundError](err); ok {
|
||||
taskNotFoundError.AskInit = true
|
||||
@@ -90,6 +91,7 @@ func (e *Executor) readTaskfile(node taskfile.Node) error {
|
||||
taskfile.WithReaderCACert(e.CACert),
|
||||
taskfile.WithReaderCert(e.Cert),
|
||||
taskfile.WithReaderCertKey(e.CertKey),
|
||||
taskfile.WithReaderHeaders(e.RemoteHeaders),
|
||||
taskfile.WithDebugFunc(debugFunc),
|
||||
taskfile.WithPromptFunc(promptFunc),
|
||||
)
|
||||
|
||||
97
taskfile/http_headers.go
Normal file
97
taskfile/http_headers.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package taskfile
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"golang.org/x/net/http/httpguts"
|
||||
|
||||
"github.com/go-task/task/v3/internal/templater"
|
||||
)
|
||||
|
||||
// HeadersByHost configures HTTP headers per host for remote Taskfiles.
|
||||
// Values support template functions, but not Taskfile variables.
|
||||
type HeadersByHost map[string]map[string]string
|
||||
|
||||
type headersTransport struct {
|
||||
base http.RoundTripper
|
||||
host string
|
||||
headers map[string]string
|
||||
}
|
||||
|
||||
func (t *headersTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// Scope all configured headers to this host, including after redirects.
|
||||
if !hostMatches(t.host, req.URL.Host) {
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
req = req.Clone(req.Context())
|
||||
for name, value := range t.headers {
|
||||
req.Header.Set(name, value)
|
||||
}
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
// Resolve headers only when downloading, so cached runs need no credentials.
|
||||
func (node *HTTPNode) clientWithHeaders() (*http.Client, error) {
|
||||
headers, err := resolveHeaders(node.headersByHost, node.url.Host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(headers) == 0 {
|
||||
return node.client, nil
|
||||
}
|
||||
return withHeaders(node.client, node.url.Host, headers), nil
|
||||
}
|
||||
|
||||
func withHeaders(client *http.Client, host string, headers map[string]string) *http.Client {
|
||||
// The client may be http.DefaultClient; leave it unchanged.
|
||||
configured := *client
|
||||
configured.Transport = &headersTransport{
|
||||
base: cmp.Or(client.Transport, http.DefaultTransport),
|
||||
host: host,
|
||||
headers: headers,
|
||||
}
|
||||
return &configured
|
||||
}
|
||||
|
||||
func resolveHeaders(headersByHost HeadersByHost, host string) (map[string]string, error) {
|
||||
var headers map[string]string
|
||||
for pattern, patternHeaders := range headersByHost {
|
||||
if hostMatches(pattern, host) {
|
||||
headers = patternHeaders
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(headers) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cache := &templater.Cache{}
|
||||
resolved := make(map[string]string, len(headers))
|
||||
for _, name := range slices.Sorted(maps.Keys(headers)) {
|
||||
if err := validateHeaderName(name); err != nil {
|
||||
return nil, fmt.Errorf(`remote headers for host %q: %w`, host, err)
|
||||
}
|
||||
resolved[name] = templater.Replace(headers[name], cache)
|
||||
}
|
||||
if err := cache.Err(); err != nil {
|
||||
return nil, fmt.Errorf(`remote headers for host %q: %w`, host, err)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// Validate names here because ReadContext hides transport errors.
|
||||
func validateHeaderName(name string) error {
|
||||
if !httpguts.ValidHeaderFieldName(name) {
|
||||
return fmt.Errorf("invalid header name %q", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Match the host and port exactly for both headers and trusted hosts.
|
||||
func hostMatches(pattern, host string) bool {
|
||||
return pattern == host
|
||||
}
|
||||
271
taskfile/http_headers_test.go
Normal file
271
taskfile/http_headers_test.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package taskfile
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResolveHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv cannot be used in parallel tests
|
||||
tests := []struct {
|
||||
name string
|
||||
headersByHost HeadersByHost
|
||||
host string
|
||||
env map[string]string
|
||||
want map[string]string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "no configuration",
|
||||
headersByHost: nil,
|
||||
host: "gitlab.com",
|
||||
},
|
||||
{
|
||||
name: "host does not match",
|
||||
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": "token"}},
|
||||
host: "example.com",
|
||||
},
|
||||
{
|
||||
name: "port is part of the host",
|
||||
headersByHost: HeadersByHost{"example.com": {"PRIVATE-TOKEN": "token"}},
|
||||
host: "example.com:8080",
|
||||
},
|
||||
{
|
||||
name: "literal value",
|
||||
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": "token"}},
|
||||
host: "gitlab.com",
|
||||
want: map[string]string{"PRIVATE-TOKEN": "token"},
|
||||
},
|
||||
{
|
||||
name: "environment variable",
|
||||
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_TOKEN"}}`}}, //nolint:gosec // an env var reference, not a credential
|
||||
host: "gitlab.com",
|
||||
env: map[string]string{"TASK_TEST_TOKEN": "s3cret"},
|
||||
want: map[string]string{"PRIVATE-TOKEN": "s3cret"},
|
||||
},
|
||||
{
|
||||
name: "environment variable inside a longer value",
|
||||
headersByHost: HeadersByHost{"gitlab.com": {"Authorization": `Bearer {{env "TASK_TEST_TOKEN"}}`}},
|
||||
host: "gitlab.com",
|
||||
env: map[string]string{"TASK_TEST_TOKEN": "s3cret"},
|
||||
want: map[string]string{"Authorization": "Bearer s3cret"},
|
||||
},
|
||||
{
|
||||
name: "undefined environment variable expands to nothing",
|
||||
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_UNSET"}}`}}, //nolint:gosec // an env var reference, not a credential
|
||||
host: "gitlab.com",
|
||||
want: map[string]string{"PRIVATE-TOKEN": ""},
|
||||
},
|
||||
{
|
||||
name: "functions compose, so Basic auth needs no manual base64",
|
||||
headersByHost: HeadersByHost{"gitlab.com": {"Authorization": `Basic {{ printf "%s:%s" (env "TASK_TEST_USER") (env "TASK_TEST_TOKEN") | b64enc }}`}},
|
||||
host: "gitlab.com",
|
||||
env: map[string]string{"TASK_TEST_USER": "alice", "TASK_TEST_TOKEN": "s3cret"},
|
||||
want: map[string]string{"Authorization": "Basic YWxpY2U6czNjcmV0"},
|
||||
},
|
||||
{
|
||||
name: "a variable reference resolves to nothing",
|
||||
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": "{{.TASK_TEST_TOKEN}}"}}, //nolint:gosec // a template, not a credential
|
||||
host: "gitlab.com",
|
||||
env: map[string]string{"TASK_TEST_TOKEN": "s3cret"},
|
||||
want: map[string]string{"PRIVATE-TOKEN": ""},
|
||||
},
|
||||
{
|
||||
name: "a literal value is left untouched",
|
||||
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": "p$ssw0rd"}}, //nolint:gosec // a test fixture
|
||||
host: "gitlab.com",
|
||||
want: map[string]string{"PRIVATE-TOKEN": "p$ssw0rd"},
|
||||
},
|
||||
{
|
||||
name: "malformed template",
|
||||
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_TOKEN"`}}, //nolint:gosec // a template, not a credential
|
||||
host: "gitlab.com",
|
||||
wantErr: `remote headers for host "gitlab.com": template: :1: unclosed action`,
|
||||
},
|
||||
{
|
||||
name: "header name with a space",
|
||||
headersByHost: HeadersByHost{"gitlab.com": {"PRIVATE TOKEN": "token"}},
|
||||
host: "gitlab.com",
|
||||
wantErr: `remote headers for host "gitlab.com": invalid header name "PRIVATE TOKEN"`,
|
||||
},
|
||||
{
|
||||
name: "header name outside the HTTP token grammar",
|
||||
headersByHost: HeadersByHost{"gitlab.com": {"X-Foo(bar)": "token"}},
|
||||
host: "gitlab.com",
|
||||
wantErr: `remote headers for host "gitlab.com": invalid header name "X-Foo(bar)"`,
|
||||
},
|
||||
{
|
||||
name: "empty header name",
|
||||
headersByHost: HeadersByHost{"gitlab.com": {"": "token"}},
|
||||
host: "gitlab.com",
|
||||
wantErr: `remote headers for host "gitlab.com": invalid header name ""`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
for name, value := range test.env {
|
||||
t.Setenv(name, value)
|
||||
}
|
||||
headers, err := resolveHeaders(test.headersByHost, test.host)
|
||||
if test.wantErr != "" {
|
||||
require.EqualError(t, err, test.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.want, headers)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadersTransport(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
transport := &headersTransport{
|
||||
base: roundTripperFunc(func(req *http.Request) (*http.Response, error) { return newResponse(req), nil }),
|
||||
host: "gitlab.com",
|
||||
headers: map[string]string{"PRIVATE-TOKEN": "token"},
|
||||
}
|
||||
|
||||
t.Run("sets the headers on the configured host", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := newRequest(t, "https://gitlab.com/api/v4/Taskfile.yml")
|
||||
resp, err := transport.RoundTrip(req)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "token", resp.Request.Header.Get("PRIVATE-TOKEN"))
|
||||
assert.Empty(t, req.Header.Get("PRIVATE-TOKEN"))
|
||||
})
|
||||
|
||||
t.Run("leaves any other host alone", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
req := newRequest(t, "https://example.com/Taskfile.yml")
|
||||
resp, err := transport.RoundTrip(req)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, resp.Request.Header.Get("PRIVATE-TOKEN"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithHeadersDoesNotMutateTheDefaultClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := withHeaders(http.DefaultClient, "gitlab.com", map[string]string{"PRIVATE-TOKEN": "token"})
|
||||
|
||||
assert.NotSame(t, http.DefaultClient, client)
|
||||
assert.Nil(t, http.DefaultClient.Transport)
|
||||
assert.IsType(t, &headersTransport{}, client.Transport)
|
||||
}
|
||||
|
||||
// Downloads probe with HEAD before GET; both requests need the headers.
|
||||
func TestHTTPNodeHeaders(t *testing.T) { //nolint:paralleltest // t.Setenv cannot be used in parallel tests
|
||||
var methods []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("PRIVATE-TOKEN") != "s3cret" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Accept") != "application/yaml" || r.Header.Get("X-Custom-Header") != "custom-value" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
methods = append(methods, r.Method)
|
||||
w.Header().Set("Content-Type", "text/yaml")
|
||||
_, _ = w.Write([]byte("version: '3'\n"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("TASK_TEST_TOKEN", "s3cret")
|
||||
node, err := NewHTTPNode(srv.URL+"/Taskfile.yml", "", true,
|
||||
WithHeaders(HeadersByHost{
|
||||
mustHost(t, srv.URL): { //nolint:gosec // an env var reference, not a credential
|
||||
"PRIVATE-TOKEN": `{{env "TASK_TEST_TOKEN"}}`,
|
||||
"Accept": "application/yaml",
|
||||
"X-Custom-Header": "custom-value",
|
||||
},
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
b, err := node.Read()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "version: '3'\n", string(b))
|
||||
assert.Equal(t, []string{"HEAD", "GET"}, methods)
|
||||
}
|
||||
|
||||
func TestHTTPNodeHeadersNotSentOnRedirect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var received []string
|
||||
elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
received = append(received, r.Header.Get("PRIVATE-TOKEN"), r.Header.Get("Accept"), r.Header.Get("X-Custom-Header"))
|
||||
w.Header().Set("Content-Type", "text/yaml")
|
||||
_, _ = w.Write([]byte("version: '3'\n"))
|
||||
}))
|
||||
defer elsewhere.Close()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, elsewhere.URL+"/Taskfile.yml", http.StatusFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
node, err := NewHTTPNode(srv.URL+"/Taskfile.yml", "", true,
|
||||
WithHeaders(HeadersByHost{
|
||||
mustHost(t, srv.URL): {
|
||||
"PRIVATE-TOKEN": "s3cret",
|
||||
"Accept": "application/yaml",
|
||||
"X-Custom-Header": "custom-value",
|
||||
},
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = node.Read()
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, received)
|
||||
for _, header := range received {
|
||||
assert.Empty(t, header, "configured headers must not follow a redirect to another host")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPNodeHeadersResolvedLazily(t *testing.T) { //nolint:paralleltest // t.Setenv cannot be used in parallel tests
|
||||
node, err := NewHTTPNode("https://gitlab.com/Taskfile.yml", "", false,
|
||||
WithHeaders(HeadersByHost{
|
||||
"gitlab.com": {"PRIVATE-TOKEN": `{{env "TASK_TEST_LAZY"}}`}, //nolint:gosec // an env var reference, not a credential
|
||||
}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set the value after construction to verify lazy resolution.
|
||||
t.Setenv("TASK_TEST_LAZY", "s3cret")
|
||||
|
||||
headers, err := resolveHeaders(node.headersByHost, node.url.Host)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"PRIVATE-TOKEN": "s3cret"}, headers)
|
||||
}
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func newRequest(t *testing.T, rawURL string) *http.Request {
|
||||
t.Helper()
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, rawURL, nil)
|
||||
require.NoError(t, err)
|
||||
return req
|
||||
}
|
||||
|
||||
func newResponse(req *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Request: req, Header: http.Header{}}
|
||||
}
|
||||
|
||||
func mustHost(t *testing.T, rawURL string) string {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(rawURL)
|
||||
require.NoError(t, err)
|
||||
return parsed.Host
|
||||
}
|
||||
@@ -7,12 +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
|
||||
caCert string
|
||||
cert string
|
||||
certKey string
|
||||
parent Node
|
||||
dir string
|
||||
checksum string
|
||||
caCert string
|
||||
cert string
|
||||
certKey string
|
||||
headersByHost HeadersByHost
|
||||
}
|
||||
)
|
||||
|
||||
@@ -75,3 +76,10 @@ func WithCertKey(certKey string) NodeOption {
|
||||
node.certKey = certKey
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaders sets the HTTP headers to send, keyed by host.
|
||||
func WithHeaders(headersByHost HeadersByHost) NodeOption {
|
||||
return func(node *baseNode) {
|
||||
node.headersByHost = headersByHost
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,11 @@ func (node *HTTPNode) Read() ([]byte, error) {
|
||||
}
|
||||
|
||||
func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) {
|
||||
url, err := RemoteExists(ctx, *node.url, node.client)
|
||||
client, err := node.clientWithHeaders()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url, err := RemoteExists(ctx, *node.url, client)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -115,7 +119,7 @@ func (node *HTTPNode) ReadContext(ctx context.Context) ([]byte, error) {
|
||||
return nil, errors.TaskfileFetchFailedError{URI: node.Location()}
|
||||
}
|
||||
|
||||
resp, err := node.client.Do(req.WithContext(ctx))
|
||||
resp, err := client.Do(req.WithContext(ctx))
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -51,6 +51,7 @@ type (
|
||||
caCert string
|
||||
cert string
|
||||
certKey string
|
||||
headersByHost HeadersByHost
|
||||
debugFunc DebugFunc
|
||||
promptFunc PromptFunc
|
||||
promptMutex sync.Mutex
|
||||
@@ -242,6 +243,19 @@ func (o *readerCertKeyOption) ApplyToReader(r *Reader) {
|
||||
r.certKey = o.certKey
|
||||
}
|
||||
|
||||
// WithReaderHeaders sets the HTTP headers to send to each configured host.
|
||||
func WithReaderHeaders(headersByHost HeadersByHost) ReaderOption {
|
||||
return &readerHeadersOption{headersByHost: headersByHost}
|
||||
}
|
||||
|
||||
type readerHeadersOption struct {
|
||||
headersByHost HeadersByHost
|
||||
}
|
||||
|
||||
func (o *readerHeadersOption) ApplyToReader(r *Reader) {
|
||||
r.headersByHost = o.headersByHost
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -286,7 +300,9 @@ func (r *Reader) isTrusted(uri string) bool {
|
||||
host := parsedURL.Host
|
||||
|
||||
// Check against each trusted pattern (exact match including port if provided)
|
||||
return slices.Contains(r.trustedHosts, host)
|
||||
return slices.ContainsFunc(r.trustedHosts, func(pattern string) bool {
|
||||
return hostMatches(pattern, host)
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Reader) include(ctx context.Context, node Node) error {
|
||||
@@ -355,6 +371,7 @@ func (r *Reader) include(ctx context.Context, node Node) error {
|
||||
WithCACert(r.caCert),
|
||||
WithCert(r.cert),
|
||||
WithCertKey(r.certKey),
|
||||
WithHeaders(r.headersByHost),
|
||||
)
|
||||
if err != nil {
|
||||
if include.Optional {
|
||||
|
||||
@@ -66,6 +66,13 @@ func RemoteExists(ctx context.Context, u url.URL, client *http.Client) (*url.URL
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// The default names need the same credentials, so trying them would only
|
||||
// add rejected requests. A 403 is left alone: it is also what a server
|
||||
// without directory listing answers for a readable directory.
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return nil, errors.TaskfileFetchFailedError{URI: u.Redacted(), HTTPStatusCode: resp.StatusCode}
|
||||
}
|
||||
|
||||
// If the request was not successful, append the default Taskfile names to
|
||||
// the URL and return the URL of the first successful request
|
||||
for _, taskfile := range DefaultTaskfiles {
|
||||
|
||||
@@ -24,15 +24,22 @@ type TaskRC struct {
|
||||
}
|
||||
|
||||
type Remote struct {
|
||||
Insecure *bool `yaml:"insecure"`
|
||||
Offline *bool `yaml:"offline"`
|
||||
Timeout *time.Duration `yaml:"timeout"`
|
||||
CacheExpiry *time.Duration `yaml:"cache-expiry"`
|
||||
CacheDir *string `yaml:"cache-dir"`
|
||||
TrustedHosts []string `yaml:"trusted-hosts"`
|
||||
CACert *string `yaml:"cacert"`
|
||||
Cert *string `yaml:"cert"`
|
||||
CertKey *string `yaml:"cert-key"`
|
||||
Insecure *bool `yaml:"insecure"`
|
||||
Offline *bool `yaml:"offline"`
|
||||
Timeout *time.Duration `yaml:"timeout"`
|
||||
CacheExpiry *time.Duration `yaml:"cache-expiry"`
|
||||
CacheDir *string `yaml:"cache-dir"`
|
||||
TrustedHosts []string `yaml:"trusted-hosts"`
|
||||
Headers []RemoteHeaders `yaml:"headers"`
|
||||
CACert *string `yaml:"cacert"`
|
||||
Cert *string `yaml:"cert"`
|
||||
CertKey *string `yaml:"cert-key"`
|
||||
}
|
||||
|
||||
// RemoteHeaders configures HTTP headers for a single host.
|
||||
type RemoteHeaders struct {
|
||||
Host string `yaml:"host"`
|
||||
Headers map[string]string `yaml:"headers"`
|
||||
}
|
||||
|
||||
// Merge combines the current TaskRC with another TaskRC, prioritizing non-nil fields from the other TaskRC.
|
||||
@@ -60,6 +67,7 @@ func (t *TaskRC) Merge(other *TaskRC) {
|
||||
slices.Sort(merged)
|
||||
t.Remote.TrustedHosts = slices.Compact(merged)
|
||||
}
|
||||
t.Remote.Headers = mergeHeaders(t.Remote.Headers, other.Remote.Headers)
|
||||
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)
|
||||
@@ -73,3 +81,19 @@ func (t *TaskRC) Merge(other *TaskRC) {
|
||||
t.Failfast = cmp.Or(other.Failfast, t.Failfast)
|
||||
t.TempDir = cmp.Or(other.TempDir, t.TempDir)
|
||||
}
|
||||
|
||||
// Replace each host's headers as a whole so closer config files can drop headers.
|
||||
func mergeHeaders(base, other []RemoteHeaders) []RemoteHeaders {
|
||||
if len(other) == 0 {
|
||||
return base
|
||||
}
|
||||
byHost := make(map[string]RemoteHeaders, len(base)+len(other))
|
||||
for _, entry := range slices.Concat(base, other) {
|
||||
byHost[entry.Host] = entry
|
||||
}
|
||||
merged := slices.Collect(maps.Values(byHost))
|
||||
slices.SortFunc(merged, func(a, b RemoteHeaders) int {
|
||||
return cmp.Compare(a.Host, b.Host)
|
||||
})
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -341,3 +341,67 @@ remote:
|
||||
assert.Equal(t, []string{"github.com", "gitlab.com"}, base.Remote.TrustedHosts)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetConfig_RemoteHeaders(t *testing.T) { //nolint:paralleltest // cannot run in parallel
|
||||
_, _, localDir := setupDirs(t)
|
||||
|
||||
configYAML := `
|
||||
remote:
|
||||
headers:
|
||||
- host: gitlab.com
|
||||
headers:
|
||||
PRIVATE-TOKEN: '{{env "GITLAB_TOKEN"}}'
|
||||
- host: example.com:8080
|
||||
headers:
|
||||
Authorization: Bearer token
|
||||
Accept: application/yaml
|
||||
X-Custom-Header: custom-value
|
||||
`
|
||||
writeFile(t, localDir, ".taskrc.yml", configYAML)
|
||||
|
||||
cfg, err := GetConfig(localDir)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
assert.Equal(t, []ast.RemoteHeaders{
|
||||
{Host: "gitlab.com", Headers: map[string]string{"PRIVATE-TOKEN": `{{env "GITLAB_TOKEN"}}`}}, //nolint:gosec // an env var reference, not a credential
|
||||
{Host: "example.com:8080", Headers: map[string]string{
|
||||
"Authorization": "Bearer token",
|
||||
"Accept": "application/yaml",
|
||||
"X-Custom-Header": "custom-value",
|
||||
}},
|
||||
}, cfg.Remote.Headers)
|
||||
}
|
||||
|
||||
func TestGetConfig_RemoteHeadersMerge(t *testing.T) { //nolint:paralleltest // cannot run in parallel
|
||||
xdgConfigDir, homeDir, localDir := setupDirs(t)
|
||||
|
||||
writeFile(t, xdgConfigDir, "taskrc.yml", `
|
||||
remote:
|
||||
headers:
|
||||
- host: gitlab.com
|
||||
headers:
|
||||
PRIVATE-TOKEN: from-xdg
|
||||
X-Extra: from-xdg
|
||||
- host: example.com
|
||||
headers:
|
||||
Authorization: from-xdg
|
||||
`)
|
||||
|
||||
// The closer file redefines gitlab.com as a whole and leaves example.com
|
||||
// untouched.
|
||||
writeFile(t, homeDir, ".taskrc.yml", `
|
||||
remote:
|
||||
headers:
|
||||
- host: gitlab.com
|
||||
headers:
|
||||
JOB-TOKEN: from-home
|
||||
`)
|
||||
|
||||
cfg, err := GetConfig(localDir)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
assert.Equal(t, []ast.RemoteHeaders{
|
||||
{Host: "example.com", Headers: map[string]string{"Authorization": "from-xdg"}},
|
||||
{Host: "gitlab.com", Headers: map[string]string{"JOB-TOKEN": "from-home"}},
|
||||
}, cfg.Remote.Headers)
|
||||
}
|
||||
|
||||
@@ -302,6 +302,81 @@ task --trusted-hosts github.com,gitlab.com -t https://github.com/user/repo.git//
|
||||
task --trusted-hosts example.com:8080 -t https://example.com:8080/Taskfile.yml
|
||||
```
|
||||
|
||||
#### `remote.headers`
|
||||
|
||||
- **Type**: `array of objects`
|
||||
- **Default**: `[]` (empty list)
|
||||
- **Description**: HTTP headers to send when downloading a remote Taskfile from
|
||||
a given host, including authentication and custom headers
|
||||
|
||||
```yaml
|
||||
remote:
|
||||
headers:
|
||||
- host: gitlab.com
|
||||
headers:
|
||||
PRIVATE-TOKEN: '{{env "GITLAB_TOKEN"}}'
|
||||
- host: artifacts.example.com:8443
|
||||
headers:
|
||||
Authorization: 'Bearer {{env "ARTIFACTS_TOKEN"}}'
|
||||
Accept: application/yaml
|
||||
X-Custom-Header: custom-value
|
||||
```
|
||||
|
||||
Use this option for authentication, content negotiation or other headers your
|
||||
server expects. For authentication, this is the recommended way to access a
|
||||
remote Taskfile. Unlike a credential placed in the URL, the header never appears
|
||||
in your Taskfile, in the confirmation prompt or in an error message, so the
|
||||
include URL stays safe to commit.
|
||||
|
||||
Each entry applies to a single host, matched exactly and including the port if
|
||||
the URL has one — the same rule as
|
||||
[`remote.trusted-hosts`](#remote-trusted-hosts). Header values may reference
|
||||
[templating functions](./templating.md), evaluated when Task contacts the host.
|
||||
Values starting with <span v-pre>`{{`</span> must be quoted, as YAML would
|
||||
otherwise read them as a mapping. An undefined environment variable expands to
|
||||
nothing, so the header is sent empty. For an authentication header, this may
|
||||
cause the server to reject the request with a `401`.
|
||||
|
||||
Functions compose, so an `Authorization` header needs no manual encoding:
|
||||
|
||||
```yaml
|
||||
remote:
|
||||
headers:
|
||||
- host: artifacts.example.com
|
||||
headers:
|
||||
Authorization: 'Basic {{ printf "%s:%s" (env "USER") (env "PASS") | b64enc }}'
|
||||
```
|
||||
|
||||
::: warning
|
||||
|
||||
Only functions are available here — <span v-pre>`{{.GITLAB_TOKEN}}`</span> and
|
||||
other variable references resolve to nothing. The configuration file is read
|
||||
before any Taskfile, so no variable exists yet. Use
|
||||
<span v-pre>`{{env "GITLAB_TOKEN"}}`</span> instead.
|
||||
|
||||
:::
|
||||
|
||||
The header your server expects depends on the service:
|
||||
|
||||
| Service | Header |
|
||||
| ----------- | -------------------------------------- |
|
||||
| GitLab API | `PRIVATE-TOKEN` (or `JOB-TOKEN` in CI) |
|
||||
| GitHub API | `Authorization: Bearer <token>` |
|
||||
| Artifactory | `X-JFrog-Art-Api` |
|
||||
|
||||
There is no CLI flag or environment variable for this option: a token given on
|
||||
the command line would be visible to any process listing it.
|
||||
|
||||
::: warning
|
||||
|
||||
Headers are only sent to the host they are configured for. If that host answers
|
||||
with a redirect to another one, the request follows the redirect **without**
|
||||
them, and will likely fail — point the URL at the final host instead. Headers
|
||||
are also HTTP-only: a Taskfile fetched over `git` should authenticate with SSH
|
||||
or a git credential helper.
|
||||
|
||||
:::
|
||||
|
||||
#### `remote.cacert`
|
||||
|
||||
- **Type**: `string`
|
||||
@@ -356,6 +431,10 @@ remote:
|
||||
trusted-hosts:
|
||||
- github.com
|
||||
- gitlab.com
|
||||
headers:
|
||||
- host: gitlab.com
|
||||
headers:
|
||||
PRIVATE-TOKEN: '{{env "GITLAB_TOKEN"}}'
|
||||
cacert: ''
|
||||
cert: ''
|
||||
cert-key: ''
|
||||
|
||||
@@ -100,6 +100,11 @@ includes:
|
||||
shared: https://{{.TOKEN}}@raw.githubusercontent.com/my-org/my-repo/main/Taskfile.yml
|
||||
```
|
||||
|
||||
Prefer the [`remote.headers`](./reference/config.md#remote-headers) configuration
|
||||
option when the server accepts a header. A credential in the URL ends up in
|
||||
error messages and in the confirmation prompt, and the include can no longer be
|
||||
committed as-is.
|
||||
|
||||
## Choose a source {#node-types}
|
||||
|
||||
### HTTP/HTTPS
|
||||
|
||||
@@ -49,6 +49,28 @@
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"type": "array",
|
||||
"description": "HTTP headers to send when downloading remote Taskfiles, per host, including authentication and custom headers.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"description": "Host the headers apply to, including the port if the URL has one (e.g., 'gitlab.com', 'example.com:8080')."
|
||||
},
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"description": "Headers to send. Values support templating functions, e.g. {{env \"GITLAB_TOKEN\"}}.",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["host", "headers"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
Reference in New Issue
Block a user