From 41c72216bf67dc4170f4d54cdb599acad9d1be08 Mon Sep 17 00:00:00 2001 From: bircni Date: Thu, 30 Jul 2026 08:15:48 +0000 Subject: [PATCH] feat: propagate proxy variables to jobs, services and builds (#1112) Set `http_proxy`, `https_proxy` and `no_proxy` in the runner's environment and everything the runner controls uses them. Go already read them for the runner's own requests. This adds jobs, in lower and upper case, service containers, and Dockerfile action builds. Some hosts are added to `no_proxy` for jobs so they stay direct: the cache server, loopback, the job's service containers, and a `tcp://` Docker daemon. Without the last one the Docker client sends its API calls to the proxy and docker-in-docker breaks. Gitea is not added. Images are pulled by the Docker daemon, which has its own proxy setting. In the `dind` images it reads these same variables. The runner warns at startup if it has a proxy and the daemon does not. Fixes https://gitea.com/gitea/runner/issues/1118, originally reported as https://gitea.com/gitea/runner/issues/708. --------- Co-authored-by: silverwind <2021+silverwind@noreply.gitea.com> Co-authored-by: silverwind Reviewed-on: https://gitea.com/gitea/runner/pulls/1112 Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com> Co-authored-by: bircni --- README.md | 29 +++++ act/container/container_types.go | 1 + act/container/docker_build.go | 1 + act/runner/action.go | 1 + act/runner/run_context.go | 18 ++- act/runner/run_context_test.go | 77 +++++++++++++ act/runner/runner.go | 1 + go.mod | 2 +- internal/app/cmd/daemon.go | 9 ++ internal/app/cmd/exec.go | 7 ++ internal/app/run/proxy.go | 163 ++++++++++++++++++++++++++ internal/app/run/proxy_test.go | 192 +++++++++++++++++++++++++++++++ internal/app/run/runner.go | 10 +- internal/app/run/runner_test.go | 18 +++ internal/pkg/report/reporter.go | 7 +- 15 files changed, 532 insertions(+), 4 deletions(-) create mode 100644 internal/app/run/proxy.go create mode 100644 internal/app/run/proxy_test.go diff --git a/README.md b/README.md index 542c5fa0..13514cfe 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,35 @@ Whenever the resulting labels differ from the ones in the registration file, the > **Note:** A runner that only exposes `host` labels still needs access to a Docker daemon (e.g. a mounted `/var/run/docker.sock`) whenever a job uses a `docker://` action or a service container. `host` labels only change where the job's own steps run; container-based steps and actions are still executed with Docker. +#### Proxy + +Set these variables in the runner's environment, with systemd `Environment=`, `docker run -e`, or Kubernetes `env:`: + +```sh +http_proxy=http://proxy.example:3128 +https_proxy=http://proxy.example:3128 +no_proxy=gitea.internal,.example.local +``` + +The runner uses them for its own requests and gives them to every job, in lower and upper case. + +These hosts are added to `no_proxy` for jobs, so they are always reached directly: + +- the cache server +- `localhost`, `127.0.0.1` and `::1` +- the job's service containers +- the Docker daemon, when it is reached over `tcp://` + +Gitea is not added. Add it to `no_proxy` yourself if it should be reached directly. + +To change a value for one job, set it in a step's `env:` or in the job's `container.env`. Setting it at workflow or job level has no effect. To change it for the whole runner, set it in `runner.envs`. A `no_proxy` set there is added to the list above instead of replacing it. + +Images are pulled by the Docker daemon, which needs its own proxy setting. In the `dind` images the daemon runs in the same container and reads the variables above. For any other daemon, see [the Docker documentation](https://docs.docker.com/engine/daemon/proxy/). The runner logs a warning at startup if it has a proxy and the daemon does not. + +Dockerfile actions are built with these variables as build arguments, so their `RUN` steps can reach the network. + +A password in a proxy URL is hidden in job logs. Any step can still read it, because the step is given the proxy URL in its environment. + #### Caching (`actions/cache`) Each runner starts its own cache server automatically. Cache entries are local to that runner — runners do not share a cache by default. diff --git a/act/container/container_types.go b/act/container/container_types.go index 363ebe85..61679807 100644 --- a/act/container/container_types.go +++ b/act/container/container_types.go @@ -82,6 +82,7 @@ type NewDockerBuildExecutorInput struct { BuildContext io.Reader ImageTag string Platform string + BuildArgs map[string]*string } // NewDockerNetworkCreateExecutorInput the input for the NewDockerNetworkCreateExecutor function diff --git a/act/container/docker_build.go b/act/container/docker_build.go index 6adedfff..2e072252 100644 --- a/act/container/docker_build.go +++ b/act/container/docker_build.go @@ -49,6 +49,7 @@ func NewDockerBuildExecutor(input NewDockerBuildExecutorInput) common.Executor { Remove: true, AuthConfigs: LoadDockerAuthConfigs(ctx), Dockerfile: input.Dockerfile, + BuildArgs: input.BuildArgs, } platform, err := parsePlatform(input.Platform) if err != nil { diff --git a/act/runner/action.go b/act/runner/action.go index 7f2712c6..a125f92e 100644 --- a/act/runner/action.go +++ b/act/runner/action.go @@ -372,6 +372,7 @@ func execAsDocker(ctx context.Context, step actionStep, actionName, actionDir, b ImageTag: image, BuildContext: buildContext, Platform: rc.Config.ContainerArchitecture, + BuildArgs: rc.proxyBuildArgs(), }) if buildContext == nil { // Held across the whole build: the daemon drains contextDir lazily. diff --git a/act/runner/run_context.go b/act/runner/run_context.go index 5b0a1b21..29e53edf 100644 --- a/act/runner/run_context.go +++ b/act/runner/run_context.go @@ -435,7 +435,9 @@ func (rc *RunContext) startJobContainer() common.Executor { continue } // interpolate env - interpolatedEnvs := make(map[string]string, len(spec.Env)) + interpolatedEnvs := make(map[string]string, len(spec.Env)+len(rc.Config.ProxyEnv)) + // a service reaches the internet the way the job does; its own env still wins + maps0.Copy(interpolatedEnvs, rc.Config.ProxyEnv) for k, v := range spec.Env { interpolatedEnvs[k] = rc.ExprEval.Interpolate(ctx, v) } @@ -969,6 +971,20 @@ func (rc *RunContext) isEnabled(ctx context.Context) (bool, error) { return true, nil } +// proxyBuildArgs returns the job's proxy variables as docker build args. The docker CLI +// pre-populates these from its own client configuration, but act builds through the API, +// so without them a Dockerfile action's RUN steps have no network behind a proxy. +func (rc *RunContext) proxyBuildArgs() map[string]*string { + if len(rc.Config.ProxyEnv) == 0 { + return nil + } + args := make(map[string]*string, len(rc.Config.ProxyEnv)) + for name, value := range rc.Config.ProxyEnv { + args[name] = &value + } + return args +} + func mergeMaps(maps ...map[string]string) map[string]string { rtnMap := make(map[string]string) for _, m := range maps { diff --git a/act/runner/run_context_test.go b/act/runner/run_context_test.go index 8c72cdb2..8179acfa 100644 --- a/act/runner/run_context_test.go +++ b/act/runner/run_context_test.go @@ -292,6 +292,83 @@ jobs: require.Equal(t, [2]string{"", ""}, credentials["redis:latest"]) } +// A service container reaches the internet the same way the job does, so it inherits the +// job's proxy; a service that sets the variable itself keeps its own value. +func TestStartJobContainerGivesServicesTheJobProxy(t *testing.T) { + workflow, err := model.ReadWorkflow(strings.NewReader(` +name: test +on: push +jobs: + job: + runs-on: ubuntu-latest + container: + image: registry.example/job:latest + services: + redis: + image: redis:latest + db: + image: postgres:latest + env: + no_proxy: db-only.example + steps: [] +`)) + require.NoError(t, err) + + var inputs []*container.NewContainerInput + origNewContainer := newContainer + newContainer = func(input *container.NewContainerInput) container.ExecutionsEnvironment { + inputs = append(inputs, input) + return fakeContainer{} + } + t.Cleanup(func() { newContainer = origNewContainer }) + + rc := &RunContext{ + Name: "test", + Config: &Config{ + Workdir: "/tmp", + ContainerNetworkMode: "host", + ReuseContainers: true, + Env: map[string]string{}, + ProxyEnv: map[string]string{"http_proxy": "http://proxy:3128", "no_proxy": "internal.example"}, + Secrets: map[string]string{}, + }, + Env: map[string]string{}, + Run: &model.Run{ + JobID: "job", + Workflow: workflow, + }, + } + rc.ExprEval = rc.NewExpressionEvaluator(t.Context()) + + require.NoError(t, rc.startJobContainer()(t.Context())) + + env := map[string][]string{} + for _, in := range inputs { + env[in.Image] = in.Env + } + + require.Contains(t, env["redis:latest"], "http_proxy=http://proxy:3128") + require.Contains(t, env["redis:latest"], "no_proxy=internal.example") + // the service's own env wins over what the runner injected, without dropping the rest + require.Contains(t, env["postgres:latest"], "no_proxy=db-only.example") + require.NotContains(t, env["postgres:latest"], "no_proxy=internal.example") + require.Contains(t, env["postgres:latest"], "http_proxy=http://proxy:3128") +} + +// act builds Dockerfile actions through the API, which does not pre-populate the proxy +// build args the docker CLI would, so the RUN steps would have no network behind a proxy. +func TestProxyBuildArgs(t *testing.T) { + rc := &RunContext{Config: &Config{ProxyEnv: map[string]string{"http_proxy": "http://proxy:3128"}}} + + args := rc.proxyBuildArgs() + + require.Len(t, args, 1) + require.Equal(t, "http://proxy:3128", *args["http_proxy"]) + + // a job without a proxy builds exactly as it does today + require.Nil(t, (&RunContext{Config: &Config{}}).proxyBuildArgs()) +} + func TestRunContext_GetBindsAndMounts(t *testing.T) { rctemplate := &RunContext{ Name: "TestRCName", diff --git a/act/runner/runner.go b/act/runner/runner.go index 45d05a7d..e38e8dea 100644 --- a/act/runner/runner.go +++ b/act/runner/runner.go @@ -73,6 +73,7 @@ type Config struct { ContainerNetworkMode docker_container.NetworkMode // the network mode of job containers (the value of --network) ContainerNetworkCreateOptions container.NewDockerNetworkCreateExecutorInput // the default network create options ActionCache ActionCache // Use a custom ActionCache Implementation + ProxyEnv map[string]string // the proxy variables the job runs with, also given to service containers and image builds PresetGitHubContext *model.GithubContext // the preset github context, overrides some fields like DefaultBranch, Env, Secrets etc. EventJSON string // the content of JSON file to use for event.json in containers, overrides EventPath diff --git a/go.mod b/go.mod index 5abc80ac..3939b248 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/timshannon/bolthold v0.0.0-20240314194003-30aac6950928 go.etcd.io/bbolt v1.5.0 go.yaml.in/yaml/v4 v4.0.0-rc.3 + golang.org/x/net v0.56.0 golang.org/x/sys v0.47.0 golang.org/x/term v0.45.0 golang.org/x/text v0.40.0 @@ -105,7 +106,6 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.53.0 // indirect - golang.org/x/net v0.56.0 // indirect golang.org/x/sync v0.22.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/internal/app/cmd/daemon.go b/internal/app/cmd/daemon.go index 2ed6f70e..a93a3384 100644 --- a/internal/app/cmd/daemon.go +++ b/internal/app/cmd/daemon.go @@ -64,6 +64,14 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu log.Warn("no labels configured, runner may not be able to pick up jobs") } + // Before the first Docker API call: the standard library resolves the proxy + // environment once. Ungated because host labels still reach the daemon. + if dockerSocketPath, err := getDockerSocketPath(cfg.Container.DockerHost); err == nil { + run.BypassProxyForDockerHost(dockerSocketPath) + } else { + log.Debugf("cannot resolve the docker socket path, so Docker API calls are not exempted from the proxy: %v", err) + } + if ls.RequireDocker() || cfg.Container.RequireDocker { // Wait for dockerd be ready if timeout := cfg.Container.DockerTimeout; timeout > 0 { @@ -101,6 +109,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu } // if dockerSocketPath passes the check, override DOCKER_HOST with dockerSocketPath os.Setenv("DOCKER_HOST", dockerSocketPath) + run.WarnIfDaemonHasNoProxy(ctx) // empty cfg.Container.DockerHost means runner need to find an available docker host automatically // and assign the path to cfg.Container.DockerHost if cfg.Container.DockerHost == "" { diff --git a/internal/app/cmd/exec.go b/internal/app/cmd/exec.go index ccbfae71..4edb05ac 100644 --- a/internal/app/cmd/exec.go +++ b/internal/app/cmd/exec.go @@ -22,6 +22,7 @@ import ( "gitea.com/gitea/runner/act/common" "gitea.com/gitea/runner/act/model" "gitea.com/gitea/runner/act/runner" + "gitea.com/gitea/runner/internal/app/run" "github.com/joho/godotenv" "github.com/moby/moby/api/types/container" @@ -415,6 +416,11 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command } handler.RegisterJob(actionsRuntimeToken, "__local/__exec") + // no service aliases: exec builds one config for the whole plan + run.BypassProxyForDockerHost(os.Getenv("DOCKER_HOST")) + proxyEnv := run.JobProxyEnv(env, env["ACTIONS_CACHE_URL"], nil) + maps.Copy(env, proxyEnv) + // run the plan config := &runner.Config{ Workdir: execArgs.Workdir(), @@ -425,6 +431,7 @@ func runExec(ctx context.Context, execArgs *executeArgs) func(cmd *cobra.Command LogOutput: true, JSONLogger: execArgs.jsonLogger, Env: env, + ProxyEnv: proxyEnv, Vars: execArgs.LoadVars(), Secrets: execArgs.LoadSecrets(), InsecureSecrets: execArgs.insecureSecrets, diff --git a/internal/app/run/proxy.go b/internal/app/run/proxy.go new file mode 100644 index 00000000..125458bb --- /dev/null +++ b/internal/app/run/proxy.go @@ -0,0 +1,163 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package run + +import ( + "context" + "net/url" + "os" + "slices" + "strings" + + "gitea.com/gitea/runner/act/container" + + log "github.com/sirupsen/logrus" + "golang.org/x/net/http/httpproxy" +) + +// proxyFromEnv returns the runner's own proxy configuration, or nil when it has none. +func proxyFromEnv() *httpproxy.Config { + cfg := httpproxy.FromEnvironment() + if cfg.HTTPProxy == "" && cfg.HTTPSProxy == "" { + return nil + } + return cfg +} + +// JobProxyEnv returns the proxy variables a job runs with, given what runner.envs already +// put in jobEnvs. Gitea is deliberately not made direct, so it stays reachable however the +// runner reaches it. +func JobProxyEnv(jobEnvs map[string]string, cacheURL string, serviceNames []string) map[string]string { + cfg := proxyFromEnv() + if cfg == nil { + return nil + } + + // Go bypasses loopback on its own, curl and most other tools in a job do not. + direct := append([]string{"localhost", "127.0.0.1", "::1"}, serviceNames...) + direct = append(direct, hostOf(cacheURL)) + + proxyEnv := map[string]string{} + setPair := func(lower, upper, value string) { + if value == "" { + return + } + // Either spelling in runner.envs takes over both, so the pair cannot disagree. + if existing, ok := jobEnvs[lower]; ok { + value = existing + } else if existing, ok := jobEnvs[upper]; ok { + value = existing + } + proxyEnv[lower], proxyEnv[upper] = value, value + } + setPair("http_proxy", "HTTP_PROXY", cfg.HTTPProxy) + setPair("https_proxy", "HTTPS_PROXY", cfg.HTTPSProxy) + + // no_proxy is merged rather than replaced: the hosts above are structural, and an + // operator cannot list the cache server's startup-assigned address in advance. + noProxy := appendNoProxy(cfg.NoProxy, direct...) + for _, name := range []string{"no_proxy", "NO_PROXY"} { + if existing, ok := jobEnvs[name]; ok { + noProxy = appendNoProxy(existing, strings.Split(noProxy, ",")...) + break + } + } + proxyEnv["no_proxy"], proxyEnv["NO_PROXY"] = noProxy, noProxy + return proxyEnv +} + +// BypassProxyForDockerHost keeps the runner's Docker API traffic off the proxy: the docker +// client proxies every transport that is not a unix socket or a named pipe, so a tcp:// +// daemon would be reached through a proxy that cannot route to it. +// +// It must run before the first Docker API call, because the standard library resolves the +// proxy environment once. +func BypassProxyForDockerHost(dockerHost string) { + cfg := proxyFromEnv() + if cfg == nil { + return + } + host := hostOf(dockerHost) + if host == "" { + // A unix socket or named pipe is never proxied. + return + } + + noProxy := appendNoProxy(cfg.NoProxy, host) + for _, name := range []string{"no_proxy", "NO_PROXY"} { + if err := os.Setenv(name, noProxy); err != nil { + log.Warnf("cannot set %s for the runner process: %v", name, err) + } + } + log.Debugf("docker host %s is reached directly, no_proxy is now %q", host, noProxy) +} + +// WarnIfDaemonHasNoProxy points at the one part the runner cannot set: the docker daemon +// pulls the images, and a daemon in its own container needs its own proxy. +func WarnIfDaemonHasNoProxy(ctx context.Context) { + if proxyFromEnv() == nil { + return + } + info, err := container.GetHostInfo(ctx) + if err != nil { + log.Debugf("cannot read the docker daemon's proxy configuration: %v", err) + return + } + if info.HTTPProxy == "" && info.HTTPSProxy == "" { + log.Warn("the runner has a proxy but the docker daemon reports none, so image pulls will not use it: https://docs.docker.com/engine/daemon/proxy/") + } +} + +// proxyPasswords returns the passwords embedded in the runner's proxy URLs, to mask before +// a job echoes its environment. +func proxyPasswords() []string { + cfg := proxyFromEnv() + if cfg == nil { + return nil + } + + var passwords []string + for _, raw := range []string{cfg.HTTPProxy, cfg.HTTPSProxy} { + parsed, err := url.Parse(raw) + if err != nil || parsed.User == nil { + continue + } + if password, ok := parsed.User.Password(); ok && password != "" && !slices.Contains(passwords, password) { + passwords = append(passwords, password) + } + } + return passwords +} + +// appendNoProxy adds hosts to a no_proxy list, keeping the operator's entries and adding +// none twice. +func appendNoProxy(noProxy string, hosts ...string) string { + entries := []string{} + for entry := range strings.SplitSeq(noProxy, ",") { + if entry = strings.TrimSpace(entry); entry != "" { + entries = append(entries, entry) + } + } + + for _, host := range hosts { + if host == "" || slices.Contains(entries, host) { + continue + } + entries = append(entries, host) + } + return strings.Join(entries, ",") +} + +// hostOf returns the host of a URL without its port, the form a no_proxy entry takes. It is +// empty for anything without a network host, such as a unix socket. +func hostOf(raw string) string { + if raw == "" { + return "" + } + parsed, err := url.Parse(strings.TrimSuffix(raw, "/")) + if err != nil || parsed.Hostname() == "" { + return "" + } + return parsed.Hostname() +} diff --git a/internal/app/run/proxy_test.go b/internal/app/run/proxy_test.go new file mode 100644 index 00000000..33e1e750 --- /dev/null +++ b/internal/app/run/proxy_test.go @@ -0,0 +1,192 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package run + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +// clearProxyEnv starts a test from a runner that has no proxy, whatever the developer's own +// environment looks like. +func clearProxyEnv(t *testing.T) { + t.Helper() + for _, name := range []string{"http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY", "no_proxy", "NO_PROXY"} { + t.Setenv(name, "") + } +} + +func TestJobProxyEnv(t *testing.T) { + const loopback = "localhost,127.0.0.1,::1" + const proxy = "http://proxy:3128" + + for _, tc := range []struct { + name string + runner map[string]string // the runner process environment + envs map[string]string // what runner.envs already put in the job + cacheURL string + services []string + want map[string]string + }{ + { + // The guarantee that makes this safe to ship: a runner without a proxy gives its + // jobs nothing at all. + name: "runner has no proxy", + cacheURL: "http://cache.local:8088/", + }, + { + name: "a lone no_proxy is not a proxy", + runner: map[string]string{"no_proxy": "example.com"}, + }, + { + name: "both spellings of every variable", + runner: map[string]string{"http_proxy": proxy, "https_proxy": proxy}, + want: map[string]string{ + "http_proxy": proxy, "HTTP_PROXY": proxy, + "https_proxy": proxy, "HTTPS_PROXY": proxy, + "no_proxy": loopback, "NO_PROXY": loopback, + }, + }, + { + name: "a variable the runner does not have is omitted", + runner: map[string]string{"https_proxy": proxy}, + want: map[string]string{ + "https_proxy": proxy, "HTTPS_PROXY": proxy, + "no_proxy": loopback, "NO_PROXY": loopback, + }, + }, + { + // The cache server and the service containers are on the local network; Gitea is + // not added and keeps being reached through the proxy. + name: "hosts the job must reach directly", + runner: map[string]string{"http_proxy": proxy, "no_proxy": "internal.example"}, + cacheURL: "http://192.168.1.10:34567/", + services: []string{"postgres", "redis"}, + want: map[string]string{ + "http_proxy": proxy, "HTTP_PROXY": proxy, + "no_proxy": "internal.example," + loopback + ",postgres,redis,192.168.1.10", + "NO_PROXY": "internal.example," + loopback + ",postgres,redis,192.168.1.10", + }, + }, + { + name: "runner.envs wins, for both spellings", + runner: map[string]string{"http_proxy": "http://from-env:3128"}, + envs: map[string]string{"http_proxy": "http://from-config:3128"}, + want: map[string]string{ + "http_proxy": "http://from-config:3128", "HTTP_PROXY": "http://from-config:3128", + "no_proxy": loopback, "NO_PROXY": loopback, + }, + }, + { + name: "runner.envs wins through the upper case spelling too", + runner: map[string]string{"http_proxy": "http://from-env:3128"}, + envs: map[string]string{"HTTP_PROXY": "http://from-config:3128"}, + want: map[string]string{ + "http_proxy": "http://from-config:3128", "HTTP_PROXY": "http://from-config:3128", + "no_proxy": loopback, "NO_PROXY": loopback, + }, + }, + { + // A runner.envs no_proxy adds to the hosts that must stay direct rather than + // replacing them, which would send cache traffic through the proxy. + name: "runner.envs no_proxy is merged, not substituted", + runner: map[string]string{"http_proxy": proxy}, + envs: map[string]string{"no_proxy": "operator.example"}, + cacheURL: "http://192.168.1.10:34567/", + want: map[string]string{ + "http_proxy": proxy, "HTTP_PROXY": proxy, + "no_proxy": "operator.example," + loopback + ",192.168.1.10", + "NO_PROXY": "operator.example," + loopback + ",192.168.1.10", + }, + }, + { + name: "runner.envs NO_PROXY is merged through the upper case spelling too", + runner: map[string]string{"http_proxy": proxy}, + envs: map[string]string{"NO_PROXY": "operator.example"}, + cacheURL: "http://192.168.1.10:34567/", + want: map[string]string{ + "http_proxy": proxy, "HTTP_PROXY": proxy, + "no_proxy": "operator.example," + loopback + ",192.168.1.10", + "NO_PROXY": "operator.example," + loopback + ",192.168.1.10", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + clearProxyEnv(t) + for name, value := range tc.runner { + t.Setenv(name, value) + } + assert.Equal(t, tc.want, JobProxyEnv(tc.envs, tc.cacheURL, tc.services)) + }) + } +} + +// docker-in-docker over tcp: the docker client would otherwise send API calls to a proxy +// that cannot route to the daemon. +func TestBypassProxyForDockerHost(t *testing.T) { + for _, tc := range []struct { + name string + httpProxy string + dockerHost string + want string + }{ + { + name: "tcp daemon is added", + httpProxy: "http://proxy:3128", + dockerHost: "tcp://docker:2375", + want: "internal.example,docker", + }, + { + name: "unix socket is left alone", + httpProxy: "http://proxy:3128", + dockerHost: "unix:///var/run/docker.sock", + want: "internal.example", + }, + { + name: "nothing happens without a proxy", + dockerHost: "tcp://docker:2375", + want: "internal.example", + }, + } { + t.Run(tc.name, func(t *testing.T) { + clearProxyEnv(t) + t.Setenv("no_proxy", "internal.example") + if tc.httpProxy != "" { + t.Setenv("http_proxy", tc.httpProxy) + } + + BypassProxyForDockerHost(tc.dockerHost) + + assert.Equal(t, tc.want, os.Getenv("no_proxy")) + }) + } +} + +func TestProxyPasswords(t *testing.T) { + clearProxyEnv(t) + t.Setenv("http_proxy", "http://user:hunter2@proxy:3128") + t.Setenv("https_proxy", "http://user:s3cret@proxy:3128") + assert.Equal(t, []string{"hunter2", "s3cret"}, proxyPasswords()) + + t.Setenv("https_proxy", "http://proxy:3128") + assert.Equal(t, []string{"hunter2"}, proxyPasswords()) +} + +func TestAppendNoProxy(t *testing.T) { + assert.Equal(t, "a.example,b.example", appendNoProxy(" a.example , b.example ")) + // a host already listed is not repeated + assert.Equal(t, "cache.local", appendNoProxy("cache.local", "cache.local")) + assert.Empty(t, appendNoProxy("", "", "")) +} + +func TestHostOf(t *testing.T) { + assert.Equal(t, "cache.local", hostOf("http://cache.local:8088/")) + assert.Equal(t, "192.168.1.10", hostOf("http://192.168.1.10:34567")) + // nothing to bypass for a socket path or an unparseable URL + assert.Empty(t, hostOf("unix:///var/run/docker.sock")) + assert.Empty(t, hostOf("cache.local:8088")) + assert.Empty(t, hostOf("://nope")) +} diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index b7fad1f3..24eee853 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strconv" "strings" "sync" @@ -268,7 +269,8 @@ func (r *Runner) Run(ctx context.Context, task *runnerv1.Task) error { ctx, cancel := context.WithTimeout(ctx, r.cfg.Runner.Timeout) defer cancel() - reporter := report.NewReporter(ctx, cancel, r.client, task, r.cfg) + // A proxy URL may carry credentials, and every job is given it; keep them out of the log. + reporter := report.NewReporter(ctx, cancel, r.client, task, r.cfg, proxyPasswords()...) var runErr error defer func() { r.runningCount.Add(-1) @@ -341,6 +343,11 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. taskContext := task.Context.Fields envs := r.cloneEnvs() + // Added per task because this job's service containers must be reached directly, and + // act reaches them by their workflow key. + proxyEnv := JobProxyEnv(envs, envs["ACTIONS_CACHE_URL"], slices.Sorted(maps.Keys(job.Services))) + maps.Copy(envs, proxyEnv) + if r.capabilities != "" { envs["GITEA_ACTIONS_CAPABILITIES"] = r.capabilities } @@ -450,6 +457,7 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. LogOutput: true, JSONLogger: false, Env: envs, + ProxyEnv: proxyEnv, Secrets: task.Secrets, GitHubInstance: strings.TrimSuffix(r.client.Address(), "/"), AutoRemove: true, diff --git a/internal/app/run/runner_test.go b/internal/app/run/runner_test.go index 6e0ff6a7..ba9b1162 100644 --- a/internal/app/run/runner_test.go +++ b/internal/app/run/runner_test.go @@ -93,6 +93,24 @@ func TestNewRunnerInitializesLabelsAndEnvironment(t *testing.T) { require.Nil(t, r.cacheHandler) } +// Proxy variables are assembled per task, because a job's service containers have to be +// reached directly and they are only known once the workflow is parsed. +func TestNewRunnerLeavesProxyToTheTask(t *testing.T) { + clearProxyEnv(t) + t.Setenv("http_proxy", "http://proxy:3128") + + cfg := &config.Config{} + cfg.Cache.ExternalServer = "http://cache.local:8088/" + reg := &config.Registration{Name: "runner"} + cli := clientmocks.NewClient(t) + cli.On("Address").Return("https://gitea.example/").Maybe() + + r := NewRunner(cfg, reg, cli) + + require.NotContains(t, r.envs, "http_proxy") + require.NotContains(t, r.envs, "no_proxy") +} + func taskWithDefaultActionsURL(url string) *runnerv1.Task { return &runnerv1.Task{ Context: &structpb.Struct{ diff --git a/internal/pkg/report/reporter.go b/internal/pkg/report/reporter.go index bb82c89d..82f2bfab 100644 --- a/internal/pkg/report/reporter.go +++ b/internal/pkg/report/reporter.go @@ -86,8 +86,13 @@ type Reporter struct { stopCommandEndToken string } -func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.Client, task *runnerv1.Task, cfg *config.Config) *Reporter { +// extraMasks are values known before the job starts that are not among its secrets, such as +// the password in the runner's proxy URL. +func NewReporter(ctx context.Context, cancel context.CancelFunc, client client.Client, task *runnerv1.Task, cfg *config.Config, extraMasks ...string) *Reporter { var oldnew []string + for _, v := range extraMasks { + oldnew = runner.AppendSecretMasker(oldnew, v) + } if v := task.Context.Fields["token"].GetStringValue(); v != "" { oldnew = runner.AppendSecretMasker(oldnew, v) }