mirror of
https://gitea.com/gitea/act_runner.git
synced 2026-11-01 12:55:54 +08:00
enhance(runner): add extra HTTP headers (#1269)
Add `runner.extra_headers` for reverse proxies that require request headers on the runner's API requests to Gitea, like registration, polling and log and status reports. Headers the runner sets itself (authentication, User-Agent, protocol headers) stay authoritative. Extra headers are dropped when a redirect changes the hostname or leaves HTTPS, while the runner's own authentication headers follow redirects as before. Invalid or case-insensitively duplicated header names are rejected at config load. Fixes: https://gitea.com/gitea/runner/issues/171 Co-authored-by: silverwind <me@silverwind.io> Reviewed-on: https://gitea.com/gitea/runner/pulls/1269 Co-authored-by: bircni <bircni@icloud.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user