diff --git a/e2e/runner_harness.go b/e2e/runner_harness.go index a4f7a97f..e76dba52 100644 --- a/e2e/runner_harness.go +++ b/e2e/runner_harness.go @@ -75,7 +75,7 @@ func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *p } labelNames := []string{label.Name} - pingCli := client.New(fixture.baseURL, cfg.Runner.Insecure, "", "", config.RequestTimeout) + pingCli := client.New(fixture.baseURL, cfg.Runner.Insecure, "", "", config.RequestTimeout, cfg.Runner.ExtraHeaders) if _, err := pingCli.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{Data: t.Name()})); err != nil { t.Fatalf("ping %s: %v", fixture.baseURL, err) } @@ -105,7 +105,7 @@ func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *p Ephemeral: regResp.Msg.Runner.Ephemeral, } - cli := client.New(fixture.baseURL, cfg.Runner.Insecure, reg.UUID, reg.Token, config.RequestTimeout) + cli := client.New(fixture.baseURL, cfg.Runner.Insecure, reg.UUID, reg.Token, config.RequestTimeout, cfg.Runner.ExtraHeaders) runner := run.NewRunner(cfg, reg, cli) declResp, err := runner.Declare(ctx, labelNames) diff --git a/internal/app/cmd/daemon.go b/internal/app/cmd/daemon.go index c86e7941..2dc812b5 100644 --- a/internal/app/cmd/daemon.go +++ b/internal/app/cmd/daemon.go @@ -174,6 +174,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu reg.UUID, reg.Token, config.RequestTimeout, + cfg.Runner.ExtraHeaders, ) runner := run.NewRunner(cfg, reg, cli) diff --git a/internal/app/cmd/register.go b/internal/app/cmd/register.go index f7866c95..a244c14d 100644 --- a/internal/app/cmd/register.go +++ b/internal/app/cmd/register.go @@ -367,6 +367,7 @@ func doRegister(ctx context.Context, cfg *config.Config, inputs *registerInputs) "", "", config.RequestTimeout, + cfg.Runner.ExtraHeaders, ) if _, err := cli.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{ diff --git a/internal/pkg/client/http.go b/internal/pkg/client/http.go index 0368335b..657735a8 100644 --- a/internal/pkg/client/http.go +++ b/internal/pkg/client/http.go @@ -6,6 +6,7 @@ package client import ( "context" "crypto/tls" + "errors" "net/http" "strings" "time" @@ -18,7 +19,7 @@ import ( "gitea.dev/actionslib/runner/v1/runnerv1connect" ) -func getHTTPClient(endpoint string, insecure bool, timeout time.Duration) *http.Client { +func getHTTPClient(endpoint string, insecure bool, timeout time.Duration, extraHeaders map[string]string) *http.Client { transport := &http.Transport{ Proxy: http.ProxyFromEnvironment, MaxIdleConns: 10, @@ -30,17 +31,39 @@ func getHTTPClient(endpoint string, insecure bool, timeout time.Duration) *http. InsecureSkipVerify: true, } } - return &http.Client{Transport: transport, Timeout: timeout} + return &http.Client{ + Transport: transport, + Timeout: timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { // a custom CheckRedirect replaces net/http's own limit + return errors.New("stopped after 10 redirects") + } + from, to := via[0].URL, req.URL + if from.Hostname() != to.Hostname() || from.Scheme == "https" && to.Scheme != "https" { + for name, value := range extraHeaders { + if req.Header.Get(name) == value { + req.Header.Del(name) + } + } + } + return nil + }, + } } // New returns a new runner client. timeout bounds every RPC: without it a // stalled connection parks the reporter for the whole job context, so logs and // heartbeats stop together and the task is reaped as a zombie. -func New(endpoint string, insecure bool, uuid, token string, timeout time.Duration, opts ...connect.ClientOption) *HTTPClient { +func New(endpoint string, insecure bool, uuid, token string, timeout time.Duration, extraHeaders map[string]string, opts ...connect.ClientOption) *HTTPClient { baseURL := strings.TrimRight(endpoint, "/") + "/api/actions" opts = append(opts, connect.WithInterceptors(connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc { return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + for name, value := range extraHeaders { + if req.Header().Get(name) == "" { // keep protocol headers such as Content-Type + req.Header().Set(name, value) + } + } req.Header().Set("User-Agent", "gitea-runner/"+ver.Version()) if uuid != "" { req.Header().Set(protocol.UUIDHeader, uuid) @@ -52,7 +75,7 @@ func New(endpoint string, insecure bool, uuid, token string, timeout time.Durati } }))) - httpClient := getHTTPClient(endpoint, insecure, timeout) + httpClient := getHTTPClient(endpoint, insecure, timeout, extraHeaders) return &HTTPClient{ PingServiceClient: pingv1connect.NewPingServiceClient( httpClient, diff --git a/internal/pkg/client/http_test.go b/internal/pkg/client/http_test.go index b575c4c2..183a58f4 100644 --- a/internal/pkg/client/http_test.go +++ b/internal/pkg/client/http_test.go @@ -4,6 +4,7 @@ package client import ( + "net" "net/http" "net/http/httptest" "strings" @@ -19,7 +20,7 @@ import ( func TestGetHTTPClientUsesProxyFromEnvironment(t *testing.T) { t.Setenv("HTTP_PROXY", "http://proxy.example.com:8080") - client := getHTTPClient("http://gitea.example.com", false, time.Minute) + client := getHTTPClient("http://gitea.example.com", false, time.Minute, nil) require.Equal(t, time.Minute, client.Timeout) transport, ok := client.Transport.(*http.Transport) require.True(t, ok) @@ -35,7 +36,7 @@ func TestGetHTTPClientUsesProxyFromEnvironment(t *testing.T) { func TestGetHTTPClientInsecureTLS(t *testing.T) { // insecure only takes effect for https endpoints - httpsInsecure := getHTTPClient("https://gitea.example.com", true, time.Minute) + httpsInsecure := getHTTPClient("https://gitea.example.com", true, time.Minute, nil) transport, ok := httpsInsecure.Transport.(*http.Transport) require.True(t, ok) require.NotNil(t, transport.TLSClientConfig) @@ -50,7 +51,7 @@ func TestGetHTTPClientInsecureTLS(t *testing.T) { {"http insecure ignored", "http://gitea.example.com", true}, } { t.Run(tc.name, func(t *testing.T) { - c := getHTTPClient(tc.endpoint, tc.insecure, time.Minute) + c := getHTTPClient(tc.endpoint, tc.insecure, time.Minute, nil) tr, ok := c.Transport.(*http.Transport) require.True(t, ok) require.Nil(t, tr.TLSClientConfig) @@ -69,7 +70,12 @@ func TestNewSetsBaseURLAndHeaders(t *testing.T) { defer server.Close() // trailing slash must be trimmed before "/api/actions" is appended - c := New(server.URL+"/", false, "the-uuid", "the-token", time.Minute) + c := New(server.URL+"/", false, "the-uuid", "the-token", time.Minute, map[string]string{ + "X-Proxy-Token": "proxy-token", + protocol.UUIDHeader: "other-uuid", + protocol.TokenHeader: "other-token", + "Content-Type": "text/plain", + }) // Address returns the endpoint as supplied (untrimmed) require.Equal(t, server.URL+"/", c.Address()) @@ -79,6 +85,8 @@ func TestNewSetsBaseURLAndHeaders(t *testing.T) { require.True(t, strings.HasPrefix(gotPath, "/api/actions/"), "unexpected path %q", gotPath) require.Equal(t, "the-uuid", gotHeaders.Get(protocol.UUIDHeader)) require.Equal(t, "the-token", gotHeaders.Get(protocol.TokenHeader)) + require.Equal(t, "proxy-token", gotHeaders.Get("X-Proxy-Token")) + require.Equal(t, "application/proto", gotHeaders.Get("Content-Type")) } func TestNewOmitsEmptyHeaders(t *testing.T) { @@ -89,9 +97,52 @@ func TestNewOmitsEmptyHeaders(t *testing.T) { })) defer server.Close() - c := New(server.URL, false, "", "", time.Minute) + c := New(server.URL, false, "", "", time.Minute, nil) _, _ = c.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Data: "hi"})) require.Empty(t, gotHeaders.Get(protocol.UUIDHeader)) require.Empty(t, gotHeaders.Get(protocol.TokenHeader)) } + +func TestNewStopsRedirectLoop(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, r.URL.Path, http.StatusTemporaryRedirect) + })) + defer server.Close() + + c := New(server.URL, false, "", "", time.Minute, nil) + _, err := c.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Data: "hi"})) + require.ErrorContains(t, err, "stopped after 10 redirects") +} + +func TestNewRedirectKeepsExtraHeadersOnlyOnSameHost(t *testing.T) { + var gotHeaders http.Header + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeaders = r.Header.Clone() + w.WriteHeader(http.StatusInternalServerError) + })) + defer target.Close() + _, port, err := net.SplitHostPort(target.Listener.Addr().String()) + require.NoError(t, err) + + for _, tc := range []struct { + newOrigin func(http.Handler) *httptest.Server + host string + kept bool + }{ + {httptest.NewServer, "127.0.0.1", true}, + {httptest.NewServer, "localhost", false}, + {httptest.NewTLSServer, "127.0.0.1", false}, + } { + gotHeaders = nil + origin := tc.newOrigin(http.RedirectHandler("http://"+net.JoinHostPort(tc.host, port), http.StatusPermanentRedirect)) + c := New(origin.URL, true, "the-uuid", "the-token", time.Minute, map[string]string{"X-Proxy-Token": "proxy-token", "Content-Type": "text/plain"}) + _, _ = c.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Data: "hi"})) + origin.Close() + + require.NotNil(t, gotHeaders, origin.URL) + require.Equal(t, tc.kept, gotHeaders.Get("X-Proxy-Token") != "", "%s to %s", origin.URL, tc.host) + require.Equal(t, "the-token", gotHeaders.Get(protocol.TokenHeader)) + require.Equal(t, "application/proto", gotHeaders.Get("Content-Type")) + } +} diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml index f18d6ecd..73fe26fe 100644 --- a/internal/pkg/config/config.example.yaml +++ b/internal/pkg/config/config.example.yaml @@ -83,6 +83,11 @@ runner: # and github_mirror is not empty. In this case, # it replaces https://github.com with the value here, which is useful for some special network environments. #github_mirror: '' + # Extra HTTP headers for runner API requests to Gitea, e.g. reverse proxy authentication. + # Not used for action clones, artifacts, job summaries or jobs. Runner headers take precedence. + # Dropped on redirects to another hostname, or to HTTP if the instance URL uses HTTPS. + #extra_headers: + # X-Proxy-Token: 'example' # When true (the default), fetch only the requested ref of an action repository (e.g. actions/checkout@v4) at depth 1 instead of cloning every branch's full history. # Set to false to clone the full history. #action_shallow_clone: true diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 712522b8..b2845d41 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -18,6 +18,7 @@ import ( "github.com/joho/godotenv" log "github.com/sirupsen/logrus" "go.yaml.in/yaml/v4" + "golang.org/x/net/http/httpguts" ) // RequestTimeout bounds every RPC to Gitea, and with it runner.fetch_timeout. @@ -60,6 +61,7 @@ type Runner struct { Timeout time.Duration `yaml:"timeout"` // Timeout specifies the duration for runner timeout. ShutdownTimeout time.Duration `yaml:"shutdown_timeout"` // ShutdownTimeout specifies the duration to wait for running jobs to complete during a shutdown of the runner. Insecure bool `yaml:"insecure"` // Insecure indicates whether the runner operates in an insecure mode. + ExtraHeaders map[string]string `yaml:"extra_headers"` // ExtraHeaders adds HTTP headers to runner API requests to Gitea. FetchTimeout time.Duration `yaml:"fetch_timeout"` // FetchTimeout specifies the timeout duration for fetching resources. FetchInterval time.Duration `yaml:"fetch_interval"` // FetchInterval specifies the interval duration for fetching resources. FetchIntervalMax time.Duration `yaml:"fetch_interval_max"` // FetchIntervalMax specifies the maximum backoff interval when idle. @@ -320,6 +322,9 @@ func LoadDefault(file string) (*Config, error) { if !slices.Contains(ToolCacheModes, cfg.Runner.ToolCacheMode) { return nil, fmt.Errorf("invalid runner.tool_cache_mode %q: must be one of %q", cfg.Runner.ToolCacheMode, ToolCacheModes) } + if err := validateExtraHeaders(cfg.Runner.ExtraHeaders); err != nil { + return nil, err + } if cfg.Host.WorkdirParent == "" { home, err := os.UserHomeDir() if err != nil { @@ -453,6 +458,21 @@ func definedRunnerConfigKeys(content []byte) (map[string]bool, error) { return defined, nil } +func validateExtraHeaders(headers map[string]string) error { + seen := make(map[string]string, len(headers)) + for name, value := range headers { + if !httpguts.ValidHeaderFieldName(name) || !httpguts.ValidHeaderFieldValue(value) { + return fmt.Errorf("invalid runner.extra_headers entry %q", name) + } + key := strings.ToLower(name) + if other, ok := seen[key]; ok { + return fmt.Errorf("runner.extra_headers sets %q and %q, which name the same header", other, name) + } + seen[key] = name + } + return nil +} + // resolveSecretFile reads key from key_file, so deployments can mount a secret instead of committing it. func resolveSecretFile(key string, secret *string, file string) error { if file == "" { diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index 0452b876..b865215f 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -89,6 +89,25 @@ func TestLoadDefault_ToolCacheMode(t *testing.T) { assert.Contains(t, err.Error(), "tool_cache_mode") } +func TestLoadDefault_LoadsExtraHeaders(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte("runner:\n extra_headers:\n X-Proxy-Token: proxy-token\n"), 0o600)) + + cfg, err := LoadDefault(path) + require.NoError(t, err) + assert.Equal(t, map[string]string{"X-Proxy-Token": "proxy-token"}, cfg.Runner.ExtraHeaders) + + for _, headers := range []string{ + "X-Proxy Token: proxy-token\n", + "X-Proxy-Token: |\n proxy-token\n", + "X-Proxy-Token: a\n x-proxy-token: b\n", + } { + require.NoError(t, os.WriteFile(path, []byte("runner:\n extra_headers:\n "+headers), 0o600)) + _, err = LoadDefault(path) + assert.ErrorContains(t, err, "runner.extra_headers", headers) + } +} + func TestLoadDefault_DefaultsWorkdirCleanupAge(t *testing.T) { cfg, err := LoadDefault("") require.NoError(t, err)