mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-06 07:10:28 +00:00
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:
1 parent
7a9fccb842
commit
aa56f46c89
22 files changed
+309
-75
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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...)
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,6 +34,6 @@ Works like `actions/checkout` with these inputs. Any other input fails the step.
|
||||
|
||||
A branch is checked out as a local branch tracking `origin`, a tag or commit as a detached `HEAD`. A triggering tag that moved since the event fails the step. The step outputs `ref` and `commit` like `actions/checkout`.
|
||||
|
||||
Persisted credentials end with the job. `token` is only sent to `github.server_url`, while `ssh-key` is offered to any SSH host, as with `actions/checkout`. The runner's `client_cert_file` is only offered to `github.server_url` and only during the step, so later steps do not get it. Without `ssh-key`, submodule URLs of the form `git@<server host>:` are fetched from `github.server_url` with `token`. With `ssh-key`, the repository is fetched from the SSH URL in the event's `repository.ssh_url`, or from the server's host on port 22.
|
||||
Persisted credentials end with the job. `token` is only sent to `github.server_url`, while `ssh-key` is offered to any SSH host, as with `actions/checkout`. The runner's `client_cert_file` is only offered to `github.server_url` and only during the step, so later steps do not get it. Its `ca_file` replaces the job image's CA bundle for `github.server_url`, in later steps too when credentials persist. Without `ssh-key`, submodule URLs of the form `git@<server host>:` are fetched from `github.server_url` with `token`. With `ssh-key`, the repository is fetched from the SSH URL in the event's `repository.ssh_url`, or from the server's host on port 22.
|
||||
|
||||
With `gitea-runner exec`, checking out the workflow's own repository copies your local working directory, unless `--no-skip-checkout` or `ssh-key` is set.
|
||||
+1
-1
@@ -109,4 +109,4 @@ Services share the pod's network, so a step reaches one by its id or `localhost`
|
||||
|
||||
## Unsupported
|
||||
|
||||
Docker actions, `docker://` steps, and the `options`, `volumes` and `credentials` of the job container fail the job, as do `volumes` and `credentials` of services. Run such workflows on a [Docker in Docker](../examples/kubernetes/README.md) runner. Pull private images with `imagePullSecrets` in `pod_template`.
|
||||
Docker actions, `docker://` steps, and the `options`, `volumes` and `credentials` of the job container fail the job, as do `volumes` and `credentials` of services. Run such workflows on a [Docker in Docker](../examples/kubernetes/README.md) runner. Pull private images with `imagePullSecrets` in `pod_template`, and mount what `container.volumes` would through its `volumes`.
|
||||
@@ -98,18 +98,20 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
||||
}
|
||||
envs := make(map[string]string, len(cfg.Runner.Envs))
|
||||
maps.Copy(envs, cfg.Runner.Envs)
|
||||
instanceTransport := client.NewTransport(cli.Address(), cfg.Runner.TLSConfig())
|
||||
var cacheHandler *artifactcache.Handler
|
||||
if cfg.Cache.Enabled == nil || *cfg.Cache.Enabled {
|
||||
warnIgnoredCachePolicy(cfg)
|
||||
warnIgnoredCacheSecret(cfg)
|
||||
handler, err := artifactcache.StartHandler(artifactcache.Options{
|
||||
Dir: cfg.Cache.Dir,
|
||||
OutboundIP: cfg.Cache.Host,
|
||||
Port: cfg.Cache.Port,
|
||||
Upstream: cfg.Cache.ExternalServer,
|
||||
S3: cacheS3(cfg),
|
||||
Policy: CachePolicy(cfg),
|
||||
Logger: logging.Default().With("module", "cache_request"),
|
||||
Dir: cfg.Cache.Dir,
|
||||
OutboundIP: cfg.Cache.Host,
|
||||
Port: cfg.Cache.Port,
|
||||
Upstream: cfg.Cache.ExternalServer,
|
||||
S3: cacheS3(cfg),
|
||||
Policy: CachePolicy(cfg),
|
||||
Logger: logging.Default().With("module", "cache_request"),
|
||||
InstanceTransport: instanceTransport,
|
||||
})
|
||||
if err != nil {
|
||||
logging.Errorf("cannot init cache server, it will be disabled: %v", err)
|
||||
@@ -140,7 +142,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
||||
runHealthCheck: executeHealthCheck,
|
||||
}
|
||||
runner.isolatedCacheContainer = sync.OnceValue(runner.detectIsolatedCacheContainer)
|
||||
runner.instanceTransport = client.NewTransport(cli.Address(), cfg.Runner.TLSConfig())
|
||||
runner.instanceTransport = instanceTransport
|
||||
return runner
|
||||
}
|
||||
|
||||
@@ -620,10 +622,12 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
||||
JobCompletedHook: r.cfg.Runner.Hooks.JobCompleted,
|
||||
Vars: task.Vars,
|
||||
ValidVolumes: r.cfg.Container.ValidVolumes,
|
||||
ContainerVolumes: r.cfg.Container.Volumes,
|
||||
SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared,
|
||||
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
||||
ClientCertFile: r.cfg.Runner.ClientCertFile,
|
||||
ClientKeyFile: r.cfg.Runner.ClientKeyFile,
|
||||
CAFile: r.cfg.Runner.CAFile,
|
||||
InstanceTransport: r.instanceTransport,
|
||||
RunnerName: r.name,
|
||||
KubernetesPicker: r.kubernetesOptions,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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-*"))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user