enhance: trust a private CA with runner.ca_file and add container.volumes (#1366)

Adds `container.volumes`, mounted into every Docker job, service and action container. Sources are paths on the Docker daemon's host and need no `valid_volumes` entry, and workflow volumes may use them only with the same access.

Adds `runner.ca_file`, a private CA trusted for the Gitea instance besides the system's by the runner's connections and its artifact proxy. Runner-side git and `builtin:checkout` use it as their CA bundle for the instance, persisted with the checkout's credentials. Jobs get it at `/var/run/act/ca.crt` with `NODE_EXTRA_CA_CERTS` pointing there unless the job or its image sets it. Git in jobs, as run by `actions/checkout`, needs the CA in the image's bundle, for example through `container.volumes`.

Fixes: https://gitea.com/gitea/runner/issues/568
Fixes: https://gitea.com/gitea/runner/issues/671
Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1366
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
This commit is contained in:
authored and silverwind committed 2026-11-05 05:28:40 +00:00
1 parent 7a9fccb842
commit aa56f46c89
22 files changed
+309 -75

No files matched your search

+11 -8
View File
@@ -104,7 +104,8 @@ type Handler struct {
stopSync context.CancelFunc
syncing sync.WaitGroup
outboundIP string
outboundIP string
instanceTransport http.RoundTripper
// internalSecret guards /_internal/{register,revoke}. When set, a remote
// runner can use these endpoints to pre-register per-job
@@ -143,19 +144,21 @@ type Options struct {
// in-process RegisterJob method directly.
InternalSecret string
Policy Policy
Logger *logging.Logger
Policy Policy
Logger *logging.Logger
InstanceTransport http.RoundTripper // reaches the instance artifact requests are forwarded to, nil for http.DefaultTransport
}
// StartHandler starts an embedded cache or forwards cache requests to Upstream.
func StartHandler(opts Options) (*Handler, error) {
dir, logger := opts.Dir, opts.Logger
h := &Handler{
creds: make(map[string]*credEntry),
internalSecret: opts.InternalSecret,
policy: opts.Policy.withDefaults(),
freeDisk: disk.FreeBytes,
dbLock: make(chan struct{}, 1),
creds: make(map[string]*credEntry),
internalSecret: opts.InternalSecret,
policy: opts.Policy.withDefaults(),
freeDisk: disk.FreeBytes,
dbLock: make(chan struct{}, 1),
instanceTransport: opts.InstanceTransport,
}
if logger == nil {
+1 -1
View File
@@ -67,7 +67,7 @@ func (h *Handler) forwardOrNotFound(w http.ResponseWriter, r *http.Request) {
h.twirpError(w, r, twirpInternal, fmt.Errorf("cache server cannot reach the artifact service at %s: %w", target, err))
},
}
if cred.InsecureTLS {
if proxy.Transport = h.instanceTransport; proxy.Transport == nil && cred.InsecureTLS { // the instance transport carries runner.insecure and the client cert
proxy.Transport = insecureTransport
}
proxy.ServeHTTP(w, r)
+17
View File
@@ -133,3 +133,20 @@ func TestFrontResultsService(t *testing.T) {
})
}
}
func TestFrontResultsServiceForwardsThroughTheInstanceTransport(t *testing.T) {
t.Parallel()
gitea := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _request *http.Request) { _, _ = io.WriteString(writer, "{}") }))
defer gitea.Close()
handler, err := StartHandler(Options{Dir: t.TempDir(), OutboundIP: "127.0.0.1", InstanceTransport: gitea.Client().Transport})
require.NoError(t, err)
defer handler.Close()
defer handler.RegisterJob("token", JobCredential{Results: gitea.URL})()
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, handler.ExternalURL()+artifactServicePath+"CreateArtifact", nil)
require.NoError(t, err)
resp, err := (&http.Client{Transport: &bearerTransport{token: "token"}}).Do(req)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
}
+11 -6
View File
@@ -185,6 +185,7 @@ type NewGitCloneExecutorInput struct {
// For Gitea
InsecureSkipTLS bool
ClientCertFile, ClientKeyFile string // PEM, absolute as git runs in other directories
CAFile string // PEM, absolute, replaces git's CA bundle
Transport http.RoundTripper // for FetchAction's archive downloads, nil for http.DefaultTransport
}
@@ -389,12 +390,16 @@ func RemoteConfig(token string, insecureSkipTLS bool) []string {
return config
}
func ClientCertConfig(prefix, certFile, keyFile, executionOS string) []string {
if certFile == "" {
return nil
// SSLConfig uses OpenSSL on Windows, as Schannel takes no PEM key and ignores sslCAInfo.
func SSLConfig(prefix, certFile, keyFile, caFile, executionOS string) []string {
var config []string
if certFile != "" {
config = append(config, prefix+"sslCert="+certFile, prefix+"sslKey="+keyFile)
}
config := []string{prefix + "sslCert=" + certFile, prefix + "sslKey=" + keyFile}
if strings.EqualFold(executionOS, "windows") {
if caFile != "" {
config = append(config, prefix+"sslCAInfo="+caFile)
}
if config != nil && strings.EqualFold(executionOS, "windows") {
config = append(config, prefix+"sslBackend=openssl")
}
return config
@@ -457,7 +462,7 @@ func runGitWithStdin(ctx context.Context, dir string, input *NewGitCloneExecutor
config := []string{"core.autocrlf=false", "core.eol=lf"} // keep action files LF
if input != nil {
config = append(config, RemoteConfig(input.Token, input.InsecureSkipTLS)...)
config = append(config, ClientCertConfig("http.", input.ClientCertFile, input.ClientKeyFile, runtime.GOOS)...)
config = append(config, SSLConfig("http.", input.ClientCertFile, input.ClientKeyFile, input.CAFile, runtime.GOOS)...)
}
if dir != "" {
args = append([]string{"-C", dir}, args...)
+9 -9
View File
@@ -54,18 +54,18 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
func TestClientCertConfig(t *testing.T) {
func TestSSLConfig(t *testing.T) {
for _, testcase := range []struct {
prefix, certFile, executionOS string
expected []string
prefix, certFile, caFile, executionOS string
expected []string
}{
{"http.", "cert.pem", "windows", []string{"http.sslCert=cert.pem", "http.sslKey=key.pem", "http.sslBackend=openssl"}},
{"http.https://example.com/.", "cert.pem", "Windows", []string{"http.https://example.com/.sslCert=cert.pem", "http.https://example.com/.sslKey=key.pem", "http.https://example.com/.sslBackend=openssl"}},
{"http.", "cert.pem", "Linux", []string{"http.sslCert=cert.pem", "http.sslKey=key.pem"}},
{"http.", "", "Windows", nil},
{"http.https://example.com/.", "cert.pem", "ca.pem", "Windows", []string{"http.https://example.com/.sslCert=cert.pem", "http.https://example.com/.sslKey=key.pem", "http.https://example.com/.sslCAInfo=ca.pem", "http.https://example.com/.sslBackend=openssl"}},
{"http.", "", "ca.pem", "windows", []string{"http.sslCAInfo=ca.pem", "http.sslBackend=openssl"}},
{"http.", "cert.pem", "ca.pem", "Linux", []string{"http.sslCert=cert.pem", "http.sslKey=key.pem", "http.sslCAInfo=ca.pem"}},
{"http.", "", "", "Windows", nil},
} {
t.Run(testcase.prefix+testcase.certFile+testcase.executionOS, func(t *testing.T) {
assert.Equal(t, testcase.expected, ClientCertConfig(testcase.prefix, testcase.certFile, "key.pem", testcase.executionOS))
t.Run(testcase.prefix+testcase.certFile+testcase.caFile+testcase.executionOS, func(t *testing.T) {
assert.Equal(t, testcase.expected, SSLConfig(testcase.prefix, testcase.certFile, "key.pem", testcase.caFile, testcase.executionOS))
})
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ func (rc *RunContext) runJobHook(ctx context.Context, hookPath, name string) err
}
rawLogger.Infof("::endgroup::")
env := mergeMaps(rc.containerSpec.Env, rc.GetEnv())
env := rc.mergeEnv(rc.caEnv, rc.containerSpec.Env, rc.GetEnv())
rc.withGithubEnv(ctx, rc.getGithubContext(ctx), env)
rc.ApplyExtraPath(ctx, &env)
+1 -1
View File
@@ -197,7 +197,7 @@ func (c Config) withInstanceAuth(input git.NewGitCloneExecutorInput, token strin
}
input.ClientCertFile, input.ClientKeyFile = c.ClientCertFile, c.ClientKeyFile
if onInstanceHost(u.Host, c.GitHubInstance) {
input.InsecureSkipTLS, input.Transport = c.InsecureSkipTLS, c.InstanceTransport
input.InsecureSkipTLS, input.CAFile, input.Transport = c.InsecureSkipTLS, c.CAFile, c.InstanceTransport
}
if u.User == nil {
input.Token = token
+9 -8
View File
@@ -130,7 +130,7 @@ func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T)
}
}
func TestCloneRemoteReusableWorkflowSendsInstanceTokenAndClientCert(t *testing.T) {
func TestCloneRemoteReusableWorkflowSendsInstanceTokenAndClientCertTrustingCA(t *testing.T) {
var gotCert bool
var gotToken string
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -150,12 +150,12 @@ func TestCloneRemoteReusableWorkflowSendsInstanceTokenAndClientCert(t *testing.T
require.NoError(t, os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0o600))
rc := &RunContext{
Config: &Config{
Env: testCheckoutEnv(),
GitHubInstance: server.URL,
Secrets: map[string]string{"GITEA_TOKEN": "token"},
InsecureSkipTLS: true,
ClientCertFile: certFile,
ClientKeyFile: keyFile,
Env: testCheckoutEnv(),
GitHubInstance: server.URL,
Secrets: map[string]string{"GITEA_TOKEN": "token"},
ClientCertFile: certFile,
ClientKeyFile: keyFile,
CAFile: certFile,
},
Run: &model.Run{JobID: "j1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"j1": {}}}},
}
@@ -188,11 +188,12 @@ func TestInstanceAuthAndTransportOnlyApplyToTheInstance(t *testing.T) {
InsecureSkipTLS: true,
ClientCertFile: "/cert",
ClientKeyFile: "/key",
CAFile: "/ca",
InstanceTransport: &http.Transport{},
}
want := git.NewGitCloneExecutorInput{URL: tt.cloneURL, InsecureSkipTLS: tt.insecure}
if tt.insecure {
want.Transport = conf.InstanceTransport
want.CAFile, want.Transport = conf.CAFile, conf.InstanceTransport
}
if tt.token {
want.Token = "token"
+64 -7
View File
@@ -68,6 +68,7 @@ type RunContext struct {
kubernetes bool
containerSpec model.ContainerSpec // container:, resolved once with platformImage
containerEnv yaml.Node // container.env of the kept containerSpec, decoded once the job env resolves
caEnv map[string]string // NODE_EXTRA_CA_CERTS for the runner CA, beneath any env the job and its image set
JobName string
ActionPath string
Parent *RunContext
@@ -278,11 +279,37 @@ func (rc *RunContext) validVolumes() []string {
if rc.dockerProxy != nil {
volumes = append(volumes, rc.dockerProxy.Socket)
}
volumes = append(volumes, rc.Config.containerVolumeSources()...)
// TODO: add a new configuration to control whether the docker daemon can be mounted
return append(volumes, name, name+"-env",
getDockerDaemonSocketMountPath(rc.containerDaemonSocket()))
}
// containerVolumeSources lets workflows mount the container.volumes sources with no more access than they give.
func (c *Config) containerVolumeSources() []string {
var sources []string
for _, spec := range c.ContainerVolumes {
parsed, _ := container.ParseVolume(spec) // validated on config load
if parsed.ReadOnly {
parsed.Source += ":ro"
}
sources = append(sources, parsed.Source)
}
return sources
}
// mountVolumes sanitizes volumes and adds the container.volumes on the targets they leave unclaimed.
func (rc *RunContext) mountVolumes(ctx context.Context, validVolumes, volumes []string) ([]string, map[string]string, map[string]bool) {
binds, mounts, claimed := splitVolumes(container.SanitizeBinds(ctx, validVolumes, volumes))
for _, spec := range rc.Config.ContainerVolumes {
if parsed, _ := container.ParseVolume(spec); !claimed[parsed.Target] { // validated on config load
claimed[parsed.Target] = true
binds = append(binds, spec)
}
}
return binds, mounts, claimed
}
func (rc *RunContext) workdirMountRoot() string {
if rc.Config.PresetGitHubContext != nil {
return filepath.Dir(filepath.Dir(rc.Config.Workdir)) // only the daemon presets, its workdir is <parent>/<owner>/<repo>
@@ -357,7 +384,7 @@ func (rc *RunContext) GetBindsAndMounts(ctx context.Context) ([]string, map[stri
ext := container.LinuxContainerEnvironmentExtensions{}
// the runner's own mounts below yield to the targets the job claims
binds, mounts, claimed := splitVolumes(container.SanitizeBinds(ctx, rc.validVolumes(), rc.containerSpec.Volumes))
binds, mounts, claimed := rc.mountVolumes(ctx, rc.validVolumes(), rc.containerSpec.Volumes)
if rc.containerDaemonSocket() != "-" && !claimed["/var/run/docker.sock"] {
binds = append(binds, rc.jobDockerSocket()+":/var/run/docker.sock")
@@ -705,7 +732,12 @@ func (rc *RunContext) jobServices(ctx context.Context, networkName string) (map[
entrypoint = []string{spec.Entrypoint}
}
serviceBinds, serviceMounts, _ := splitVolumes(spec.Volumes)
serviceBinds, serviceMounts, _ := splitVolumes(spec.Volumes) // as given on Kubernetes, which rejects them
validVolumes := rc.Config.ValidVolumes // not validVolumes(), a service gets no docker socket
if !rc.kubernetes {
validVolumes = append(slices.Clone(validVolumes), rc.Config.containerVolumeSources()...)
serviceBinds, serviceMounts, _ = rc.mountVolumes(ctx, validVolumes, spec.Volumes)
}
exposedPorts, portBindings, err := nat.ParsePortSpecs(spec.Ports)
if err != nil {
@@ -734,7 +766,7 @@ func (rc *RunContext) jobServices(ctx context.Context, networkName string) (map[
NetworkAliases: []string{serviceID},
ExposedPorts: exposedPorts,
PortBindings: portBindings,
ValidVolumes: rc.Config.ValidVolumes, // not validVolumes(), a service gets no docker socket
ValidVolumes: validVolumes,
AllocatePTY: rc.Config.AllocatePTY,
}
}
@@ -1119,10 +1151,35 @@ func (svc *serviceContainer) healthOutputSuffix() string {
}
func (rc *RunContext) copyWorkflowFiles() common.Executor {
return rc.JobContainer.Copy(rc.JobContainer.GetActPath()+"/",
&container.FileEntry{Name: "workflow/event.json", Mode: 0o644, Body: rc.EventJSON},
&container.FileEntry{Name: "workflow/envs.txt", Mode: 0o666},
)
return func(ctx context.Context) error {
files := []*container.FileEntry{
{Name: "workflow/event.json", Mode: 0o644, Body: rc.EventJSON},
{Name: "workflow/envs.txt", Mode: 0o666},
}
if rc.Config.CAFile != "" {
ca, err := os.ReadFile(rc.Config.CAFile)
if err != nil {
return fmt.Errorf("read runner CA file: %w", err)
}
files = append(files, &container.FileEntry{Name: "ca.crt", Mode: 0o644, Body: string(ca)})
imageEnv := map[string]string{}
if err = rc.JobContainer.UpdateFromImageEnv(&imageEnv)(ctx); err != nil {
return err
}
if _, set := imageEnv["NODE_EXTRA_CA_CERTS"]; !set {
rc.caEnv = map[string]string{"NODE_EXTRA_CA_CERTS": rc.jobCAFile()}
}
}
return rc.JobContainer.Copy(rc.JobContainer.GetActPath()+"/", files...)(ctx)
}
}
// jobCAFile is where copyWorkflowFiles puts the runner CA in the job, empty without one.
func (rc *RunContext) jobCAFile() string {
if rc.Config.CAFile == "" {
return ""
}
return rc.JobContainer.GetActPath() + "/ca.crt"
}
// captureJobContainerInfo is a convenience: failing to describe the container must not
+56 -6
View File
@@ -277,25 +277,38 @@ jobs:
require.Equal(t, [2]string{"", ""}, credentials["redis:latest"])
}
func TestStartJobContainerGivesServicesTheirVolumes(t *testing.T) {
redis := startJobContainerInputs(t, `
func TestStartJobContainerMountsWorkflowVolumesAndContainerVolumesOnUnclaimedTargets(t *testing.T) {
const ca, certs = "/host/ca.crt:/etc/ssl/certs/ca-certificates.crt:ro", "/host/certs:/certs"
inputs := startJobContainerInputs(t, `
jobs:
job:
container:
image: node:24
volumes:
- /job/certs:/certs
services:
redis:
image: redis:latest
volumes:
- data:/data
- data:/backup
`, &Config{ValidVolumes: []string{"data"}})[0]
- /blocked/ca.crt:/etc/ssl/certs/ca-certificates.crt:ro
- blocked:/certs
`, &Config{ValidVolumes: []string{"data", "/job/certs"}, ContainerVolumes: []string{ca, certs}})
require.Len(t, inputs, 2)
redis, job := inputs[0], inputs[1]
require.Equal(t, "redis:latest", redis.Image)
require.Equal(t, []string{"data"}, redis.ValidVolumes)
require.Equal(t, []string{"data", "/job/certs", "/host/ca.crt:ro", "/host/certs"}, redis.ValidVolumes)
require.Equal(t, map[string]string{"data": "/data"}, redis.Mounts)
require.Equal(t, []string{"data:/backup"}, redis.Binds)
require.Equal(t, []string{"data:/backup", ca, certs}, redis.Binds)
require.Empty(t, redis.WorkingDir)
require.Nil(t, redis.Entrypoint)
require.Nil(t, redis.Cmd)
assert.Subset(t, job.Binds, []string{"/job/certs:/certs", ca})
assert.NotContains(t, job.Binds, certs)
assert.Subset(t, job.ValidVolumes, []string{"/job/certs", "/host/ca.crt:ro", "/host/certs"})
assert.NotContains(t, job.ValidVolumes, "/host/ca.crt", "a workflow must not mount a read-only source writable")
}
func TestStartJobContainerBuildsEvaluatedJobAndServiceInputs(t *testing.T) {
@@ -304,7 +317,7 @@ jobs:
job:
container: ${{ fromJSON('{"image":"docker://node:20","options":"--label ${{ github.job }}","credentials":{"username":"user","password":"${{ vars.TITLE }}"}}') }}
services: ${{ fromJSON('{"redis":{"image":"docker://redis:latest","volumes":["data:/data"],"env":{"TITLE":"${{ github.job }}"},"credentials":{"username":"user","password":"${{ github.job }}"},"entrypoint":"/entry.sh","command":"redis-server --port 6380"}}') }}
`, &Config{})
`, &Config{ValidVolumes: []string{"data"}})
redis, job := inputs[0], inputs[1]
require.Equal(t, "redis:latest", redis.Image)
@@ -1450,6 +1463,43 @@ func TestGetJobContextReportsContainers(t *testing.T) {
}, jobContext.Services)
}
type imageEnvContainer struct {
*container.HostEnvironment
imageEnv map[string]string
}
func (c imageEnvContainer) UpdateFromImageEnv(env *map[string]string) common.Executor {
return func(_ctx context.Context) error { maps.Copy(*env, c.imageEnv); return nil }
}
func TestCopyWorkflowFilesHandsNodeTheRunnerCAUnlessTheJobOrImageSetsIt(t *testing.T) {
caFile, actPath := filepath.Join(t.TempDir(), "ca.crt"), t.TempDir()
require.NoError(t, os.WriteFile(caFile, []byte("PEM\n"), 0o600))
own := map[string]string{"NODE_EXTRA_CA_CERTS": "/own.pem"}
stepEnv := func(caFile string, jobEnv, containerEnv, imageEnv map[string]string) map[string]string {
rc := &RunContext{
Config: &Config{CAFile: caFile},
Run: &model.Run{JobID: "job", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"job": {}}}},
Env: maps.Clone(jobEnv),
JobContainer: imageEnvContainer{&container.HostEnvironment{Path: t.TempDir(), ActPath: actPath, StdOut: io.Discard}, imageEnv},
}
rc.containerSpec.Env = containerEnv
require.NoError(t, rc.copyWorkflowFiles()(t.Context()))
env := map[string]string{}
require.NoError(t, setupEnv(t.Context(), &stepRun{RunContext: rc, Step: &model.Step{}, env: env}))
return env
}
assert.Equal(t, actPath+"/ca.crt", stepEnv(caFile, nil, nil, nil)["NODE_EXTRA_CA_CERTS"])
ca, err := os.ReadFile(filepath.Join(actPath, "ca.crt"))
require.NoError(t, err)
assert.Equal(t, "PEM\n", string(ca))
assert.Equal(t, "/own.pem", stepEnv(caFile, own, nil, nil)["NODE_EXTRA_CA_CERTS"])
assert.Equal(t, "/own.pem", stepEnv(caFile, nil, own, nil)["NODE_EXTRA_CA_CERTS"])
assert.NotContains(t, stepEnv(caFile, nil, nil, own), "NODE_EXTRA_CA_CERTS")
assert.NotContains(t, stepEnv("", nil, nil, nil), "NODE_EXTRA_CA_CERTS")
}
func TestCaptureJobContainerInfoExportsDockerWorkspace(t *testing.T) {
workdir := t.TempDir()
job := &containerMock{}
+2
View File
@@ -83,9 +83,11 @@ type Config struct {
InstanceTransport http.RoundTripper
JobLoggerLevel *logging.Level // the level of job logger
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
ContainerVolumes []string // mounted into every job, service and docker action container
SharedToolCache bool // one tool cache for all jobs instead of one per job
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
ClientCertFile, ClientKeyFile string // PEM client certificate for the Gitea instance
CAFile string // PEM CA certificates the Gitea instance's certificate chains to
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
AllocatePTY bool // allocate a pseudo-TTY for each step's process
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
+1 -1
View File
@@ -263,7 +263,7 @@ func setupEnv(ctx context.Context, step step) error {
env := step.getEnv()
// container env is the image's baseline, which job env and $GITHUB_ENV override
mergeIntoMap(step, env, rc.containerSpec.Env, rc.GetEnv())
mergeIntoMap(step, env, rc.caEnv, rc.containerSpec.Env, rc.GetEnv())
rc.withGithubEnv(ctx, getStepGithubContext(ctx, step), *env)
var err error
+1
View File
@@ -49,6 +49,7 @@ func (sab *stepActionBuiltin) main() common.Executor {
InsecureSkipTLS: rc.Config.InsecureSkipTLS,
ClientCertFile: rc.Config.ClientCertFile,
ClientKeyFile: rc.Config.ClientKeyFile,
CAFile: rc.jobCAFile(),
Env: sab.env,
})
})