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

+1
View File
@@ -22,5 +22,6 @@ type Context struct {
InsecureSkipTLS bool
ClientCertFile string
ClientKeyFile string
CAFile string // the runner CA as seen inside Container
Env map[string]string
}
+5 -5
View File
@@ -124,7 +124,7 @@ func Main(ctx context.Context, c *action.Context) (err error) {
credentials := fmt.Sprintf("git-credentials-%x.config", digest[:8])
co.credentials = container.JoinPath(runnerTemp, credentials)
persist := boolInput(c.Inputs, "persist-credentials", true)
config, remoteConfig := serverConfig(ghc.ServerURL, cmp.Or(c.Inputs["token"], ghc.Token), c.InsecureSkipTLS, co.submodules != "" && sshKey == "")
config, remoteConfig := serverConfig(ghc.ServerURL, append(git.RemoteConfig(cmp.Or(c.Inputs["token"], ghc.Token), c.InsecureSkipTLS), git.SSLConfig("http.", "", "", c.CAFile, executionOS)...), co.submodules != "" && sshKey == "")
if boolInput(c.Inputs, "set-safe-directory", true) {
remoteConfig = append(remoteConfig, "safe.directory=*") // a bound workdir belongs to the host user
}
@@ -145,7 +145,7 @@ func Main(ctx context.Context, c *action.Context) (err error) {
if err := c.Container.Copy(runnerTemp, &container.FileEntry{Name: cert, Mode: 0o600, Body: string(certPEM)}, &container.FileEntry{Name: key, Mode: 0o600, Body: string(keyPEM)})(ctx); err != nil {
return err
}
remoteConfig = append(remoteConfig, git.ClientCertConfig("http."+strings.TrimSuffix(ghc.ServerURL, "/")+"/.", container.JoinPath(runnerTemp, cert), container.JoinPath(runnerTemp, key), executionOS)...)
remoteConfig = append(remoteConfig, git.SSLConfig("http."+strings.TrimSuffix(ghc.ServerURL, "/")+"/.", container.JoinPath(runnerTemp, cert), container.JoinPath(runnerTemp, key), "", executionOS)...)
}
env := gitEnv(c, remoteConfig)
env["GIT_LFS_SKIP_SMUDGE"] = strconv.FormatBool(!co.lfs || len(co.sparse) == 0) // lfs pull fetches the objects of a full checkout in one go
@@ -265,13 +265,13 @@ func lsRemote(ctx context.Context, c *action.Context, env map[string]string, url
}
// serverConfig scopes the token to the server, so no other host like a repository's LFS endpoint gets it.
func serverConfig(serverURL, token string, insecureSkipTLS, rewriteSSH bool) (file string, env []string) {
func serverConfig(serverURL string, settings []string, rewriteSSH bool) (file string, env []string) {
scope := strings.TrimSuffix(serverURL, "/") + "/"
file = fmt.Sprintf("[http %q]\n", scope)
for _, entry := range git.RemoteConfig(token, insecureSkipTLS) {
for _, entry := range settings {
setting := strings.TrimPrefix(entry, "http.")
key, value, _ := strings.Cut(setting, "=")
file += "\t" + key + " = " + value + "\n"
file += "\t" + key + " = " + strconv.Quote(value) + "\n"
env = append(env, "http."+scope+"."+setting)
}
if server, err := url.Parse(serverURL); rewriteSSH && err == nil && server.Hostname() != "" { // submodules on the server fetch over HTTP with the token
+11 -9
View File
@@ -215,6 +215,7 @@ exec "$LFS" "$@"
Workspace: workspace,
BindWorkdir: tc.bind,
NoSkipCheckout: tc.noSkip,
CAFile: "ca.crt",
Env: map[string]string{
"PATH": tools + string(os.PathListSeparator) + os.Getenv("PATH"), "GIT_CONFIG_GLOBAL": globalConfig, "GIT_CONFIG_NOSYSTEM": "1",
"GIT_DIR": host, "GITHUB_OUTPUT": output, "SSH_SERVER": filepath.ToSlash(server), "SSH_ARGS": filepath.Join(runnerTemp, "ssh-args"),
@@ -283,6 +284,7 @@ exec "$LFS" "$@"
assert.Len(t, strings.Fields(gitOutput(repo, "config", "--local", "--get-all", credentialsInclude)), 1, repo)
headers, _ := exec.Command("git", "-C", repo, "config", "--get-all", "http."+serverURL+"/.extraheader").Output()
assert.Equal(t, persisted, string(headers) == "\nAuthorization: Basic dG9rZW46dG9r\n", repo)
assert.Equal(t, persisted, gitOutput(repo, "config", "http."+serverURL+"/.sslcainfo") != "", repo)
}
if tc.inputs["lfs"] == "true" && runtime.GOOS != "windows" {
headers, _ := os.ReadFile(filepath.Join(runnerTemp, "lfs-headers"))
@@ -306,7 +308,7 @@ exec "$LFS" "$@"
}
}
func TestCheckoutOffersClientCertOnlyDuringTheStep(t *testing.T) {
func TestCheckoutTrustsTheRunnerCAAndOffersClientCertOnlyDuringTheStep(t *testing.T) {
var gotCert bool
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotCert = len(r.TLS.PeerCertificates) > 0
@@ -324,14 +326,14 @@ func TestCheckoutOffersClientCertOnlyDuringTheStep(t *testing.T) {
require.NoError(t, os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0o600))
require.ErrorContains(t, Main(t.Context(), &action.Context{
Container: &container.HostEnvironment{Path: workspace, TmpDir: runnerTemp, StdOut: io.Discard},
Github: &model.GithubContext{Repository: "org/repo", Ref: "refs/heads/main", ServerURL: server.URL},
Inputs: map[string]string{"repository": "other/repo"},
Workspace: workspace,
InsecureSkipTLS: true,
ClientCertFile: certFile,
ClientKeyFile: keyFile,
Env: map[string]string{"PATH": os.Getenv("PATH"), "GIT_CONFIG_GLOBAL": os.DevNull, "GIT_CONFIG_NOSYSTEM": "1"},
Container: &container.HostEnvironment{Path: workspace, TmpDir: runnerTemp, StdOut: io.Discard},
Github: &model.GithubContext{Repository: "org/repo", Ref: "refs/heads/main", ServerURL: server.URL},
Inputs: map[string]string{"repository": "other/repo"},
Workspace: workspace,
ClientCertFile: certFile,
ClientKeyFile: keyFile,
CAFile: certFile,
Env: map[string]string{"PATH": os.Getenv("PATH"), "GIT_CONFIG_GLOBAL": os.DevNull, "GIT_CONFIG_NOSYSTEM": "1"},
}), "checkout: other/repo@")
require.True(t, gotCert)
keys, _ := filepath.Glob(filepath.Join(runnerTemp, "git-client-key-*"))
+15
View File
@@ -44,6 +44,12 @@ runner:
# PEM client certificate and key for a Gitea instance that requires mutual TLS.
#client_cert_file: /etc/gitea-runner/client.crt
#client_key_file: /etc/gitea-runner/client.key
# PEM CA certificates of a Gitea instance with a private CA, trusted besides the system's.
# Runner-side git and builtin:checkout use it instead of the system's bundle for the instance.
# Jobs get it at /var/run/act/ca.crt, or in their act directory on the host, with NODE_EXTRA_CA_CERTS
# pointing there unless the job, or on Docker its image, sets it. Other tools in jobs, such as the
# git that actions/checkout runs, need the CA in the image's bundle, for example via container.volumes.
#ca_file: /etc/gitea-runner/ca.crt
# The timeout for fetching the job from the Gitea instance.
# Values above the 60s RPC timeout are capped to it.
#fetch_timeout: 5s
@@ -264,6 +270,15 @@ container:
# valid_volumes:
# - '**'
#valid_volumes: []
# Volumes and bind mounts the runner adds to every job, service and docker action container, as in
# `docker run --volume`, with sources on the docker daemon's host. One the workflow mounts on the same
# container path takes its place. Their sources need no valid_volumes entry, and workflow volumes may
# use them only with the same access, though a job given the docker socket can mount anything.
# Docker only, use pod_template on Kubernetes. For example, to trust a private CA in Debian and
# Ubuntu based images:
# volumes:
# - /etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro
#volumes: []
# Overrides the docker client host with the specified one.
# If it's empty, runner will find an available docker host automatically.
# If it's "-", runner will find an available docker host automatically, but won't mount it into the job and action containers.
+33 -1
View File
@@ -5,6 +5,7 @@ package config
import (
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"maps"
@@ -16,7 +17,9 @@ import (
"time"
"gitea.com/gitea/runner/act/common/logging"
"gitea.com/gitea/runner/act/container"
"github.com/docker/go-connections/tlsconfig"
"github.com/docker/go-units"
"github.com/joho/godotenv"
"go.yaml.in/yaml/v4"
@@ -67,6 +70,7 @@ type Runner struct {
Insecure bool `yaml:"insecure"` // Insecure indicates whether the runner operates in an insecure mode.
ClientCertFile string `yaml:"client_cert_file"` // ClientCertFile is the PEM certificate presented to a Gitea instance that requires mutual TLS.
ClientKeyFile string `yaml:"client_key_file"` // ClientKeyFile is the PEM private key paired with ClientCertFile.
CAFile string `yaml:"ca_file"` // CAFile holds PEM CA certificates trusted for the Gitea instance besides the system's.
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.
@@ -90,6 +94,8 @@ type Runner struct {
PostTaskScript string `yaml:"post_task_script"` // PostTaskScript is the path to an executable script run on the host after each task's cleanup completes. Empty disables the hook. On Windows use .exe/.bat/.cmd; PowerShell (.ps1) is not supported yet as the configured path.
PostTaskScriptTimeout time.Duration `yaml:"post_task_script_timeout"` // PostTaskScriptTimeout caps how long the post-task script may run. Default is 5m when post_task_script is set.
Hooks RunnerHooks `yaml:"hooks"` // Hooks are scripts run inside the job environment around the job's steps.
rootCAs *x509.CertPool // the system's and CAFile's, nil for the system's alone
}
// RunnerHooks represents the scripts run inside the job environment around the job's steps.
@@ -176,6 +182,7 @@ type Container struct {
Options string `yaml:"options"` // Options specifies additional options for the container.
WorkdirParent string `yaml:"workdir_parent"` // WorkdirParent specifies the parent directory for the container's working directory.
ValidVolumes []string `yaml:"valid_volumes"` // ValidVolumes specifies the volumes (including bind mounts) can be mounted to containers.
Volumes []string `yaml:"volumes"` // Volumes are mounted into every job, service and docker action container.
DockerHost string `yaml:"docker_host"` // DockerHost specifies the Docker host. It overrides the value specified in environment variable DOCKER_HOST.
ForcePull bool `yaml:"force_pull"` // Pull docker image(s) even if already present, except digest-pinned ones. A pull that fails while a local copy exists is a warning, not a job failure.
ForceRebuild bool `yaml:"force_rebuild"` // Rebuild docker image(s) even if already present
@@ -313,6 +320,17 @@ func LoadDefault(file string) (*Config, error) {
}
}
if cfg.Runner.CAFile != "" {
if err := cfg.Runner.loadCAFile(); err != nil {
return nil, err
}
}
for _, spec := range cfg.Container.Volumes {
if parsed, err := container.ParseVolume(spec); err != nil || parsed.Source == "" {
return nil, fmt.Errorf("container.volumes entry %q is not source:target[:options]", spec)
}
}
if cfg.Log.Level == "" {
cfg.Log.Level = "info"
}
@@ -465,8 +483,22 @@ func LoadDefault(file string) (*Config, error) {
return cfg, nil
}
func (r *Runner) loadCAFile() error {
abs, err := filepath.Abs(r.CAFile) // git resolves a relative path against the repository it runs in
if err != nil {
return err
}
r.CAFile = abs
tlsConfig, err := tlsconfig.Client(tlsconfig.Options{CAFile: r.CAFile})
if err != nil {
return fmt.Errorf("load runner CA file: %w", err)
}
r.rootCAs = tlsConfig.RootCAs
return nil
}
func (r *Runner) TLSConfig() *tls.Config {
tlsConfig := &tls.Config{InsecureSkipVerify: r.Insecure}
tlsConfig := &tls.Config{InsecureSkipVerify: r.Insecure, RootCAs: r.rootCAs}
if r.ClientCertFile != "" {
tlsConfig.GetClientCertificate = func(info *tls.CertificateRequestInfo) (*tls.Certificate, error) {
pair, err := tls.LoadX509KeyPair(r.ClientCertFile, r.ClientKeyFile) // per handshake, so renewed files apply
+46 -2
View File
@@ -10,7 +10,11 @@ import (
"crypto/tls"
"crypto/x509"
"encoding/pem"
"io"
"log"
"math/big"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -66,6 +70,46 @@ func TestClientCertIsValidatedAndAbsoluteOnLoadReadOnUseAndOfferedOnlyWhenAccept
require.Empty(t, pair.Certificate)
}
func TestCAFileIsValidatedAndAbsoluteOnLoadAndTrustedBesidesTheSystem(t *testing.T) {
server := httptest.NewUnstartedServer(http.HandlerFunc(func(_writer http.ResponseWriter, _request *http.Request) {}))
server.Config.ErrorLog = log.New(io.Discard, "", 0)
server.StartTLS()
defer server.Close()
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "ca.crt"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0o600))
require.NoError(t, os.WriteFile(filepath.Join(dir, "empty.crt"), nil, 0o600))
load := func(file string) (*Config, error) { return LoadDefault(write(t, "runner:\n ca_file: '"+file+"'\n")) }
t.Chdir(dir)
_, err := load("empty.crt")
require.ErrorContains(t, err, "failed to append certificates")
_, err = load("missing.crt")
require.ErrorContains(t, err, "could not read CA certificate")
cfg, err := load("ca.crt")
require.NoError(t, err)
require.Equal(t, filepath.Join(dir, "ca.crt"), cfg.Runner.CAFile)
get := func(runner *Runner) error {
resp, err := (&http.Client{Transport: &http.Transport{TLSClientConfig: runner.TLSConfig()}}).Get(server.URL)
if err == nil {
resp.Body.Close()
}
return err
}
require.Error(t, get(&Runner{}))
require.NoError(t, get(&cfg.Runner))
}
func TestLoadDefault_RejectsContainerVolumesWithoutSource(t *testing.T) {
cfg, err := LoadDefault(write(t, "container:\n volumes: ['/etc/ssl/certs:/etc/ssl/certs:ro', 'data:/data']\n"))
require.NoError(t, err)
require.Equal(t, []string{"/etc/ssl/certs:/etc/ssl/certs:ro", "data:/data"}, cfg.Container.Volumes)
for _, spec := range []string{"/etc/ssl/certs", "/a:/b:ro:/c"} {
_, err = LoadDefault(write(t, "container:\n volumes: ['"+spec+"']\n"))
require.ErrorContains(t, err, "is not source:target", spec)
}
}
func TestLoadDefault_ExpandsEnvironmentValues(t *testing.T) {
t.Setenv("RUNNER_CAPACITY", "4")
t.Setenv("RUNNER_LABEL", "linux:host")
@@ -307,11 +351,11 @@ func TestLoadDefault_MalformedYAMLReturnsParseError(t *testing.T) {
func TestLoadDefault_WarnsOnUnknownKeysButStillLoads(t *testing.T) {
hook := logtest.SetDefault(t)
cfg, err := LoadDefault(write(t, "container:\n volumes:\n - /host:/ctr\n privileged: true\n"))
cfg, err := LoadDefault(write(t, "container:\n mounts:\n - /host:/ctr\n privileged: true\n"))
require.NoError(t, err)
assert.True(t, cfg.Container.Privileged)
require.Len(t, hook.AllRecords(), 1)
assert.Contains(t, hook.LastRecord().Message, "field volumes not found")
assert.Contains(t, hook.LastRecord().Message, "field mounts not found")
}
func TestContainerNetworkCreateOptions(t *testing.T) {