enhance: support client certificates for mutual TLS (#1273)

Add `runner.client_cert_file` and `runner.client_key_file` for Gitea instances that require mutual TLS. The runner presents the certificate on its RPCs, job summary uploads, and the action, reusable workflow and `builtin:checkout` clones from the instance. The files are read on use, so renewed certificates apply without a restart. Job steps do not get them. An incomplete or mismatched pair fails startup.

Clones now decide the token, client certificate and `runner.insecure` with one host check, which also gives reusable workflow clones the TLS settings they lacked. `runner.insecure` still only covers the registered address.

Fixes: https://gitea.com/gitea/runner/issues/321
Co-authored-by: silverwind <[email protected]>
Co-authored-by: silverwind <[email protected]>
Reviewed-on: https://gitea.com/gitea/runner/pulls/1273
Reviewed-by: silverwind <[email protected]>
Co-authored-by: bircni <[email protected]>
This commit is contained in:
authored and silverwind committed 2026-10-08 17:48:31 +00:00
1 parent e5aa9add02
commit f963e9cfe8
21 files changed
+298 -202

No files matched your search

+3
View File
@@ -41,6 +41,9 @@ runner:
#shutdown_timeout: 0s
# Whether skip verifying the TLS certificate of the Gitea instance.
#insecure: false
# 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
# The timeout for fetching the job from the Gitea instance.
# Values above the 60s RPC timeout are capped to it.
#fetch_timeout: 5s
+32
View File
@@ -4,6 +4,7 @@
package config
import (
"crypto/tls"
"errors"
"fmt"
"maps"
@@ -63,6 +64,8 @@ type Runner struct {
Timeout time.Duration `yaml:"timeout"` // Timeout specifies the duration for runner timeout.
ShutdownTimeout time.Duration `yaml:"shutdown_timeout"` // ShutdownTimeout specifies the duration to wait for running jobs to complete during a shutdown of the runner.
Insecure bool `yaml:"insecure"` // Insecure indicates whether the runner operates in an insecure mode.
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.
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.
@@ -272,6 +275,21 @@ func LoadDefault(file string) (*Config, error) {
maps.Copy(cfg.Runner.Envs, envs)
}
}
if (cfg.Runner.ClientCertFile == "") != (cfg.Runner.ClientKeyFile == "") {
return nil, errors.New("runner.client_cert_file and runner.client_key_file must be set together")
}
if cfg.Runner.ClientCertFile != "" {
if _, err := tls.LoadX509KeyPair(cfg.Runner.ClientCertFile, cfg.Runner.ClientKeyFile); err != nil {
return nil, fmt.Errorf("load runner client certificate: %w", err)
}
for _, file := range []*string{&cfg.Runner.ClientCertFile, &cfg.Runner.ClientKeyFile} {
abs, err := filepath.Abs(*file) // git resolves a relative path against the repository it runs in
if err != nil {
return nil, err
}
*file = abs
}
}
if cfg.Log.Level == "" {
cfg.Log.Level = "info"
@@ -422,6 +440,20 @@ func LoadDefault(file string) (*Config, error) {
return cfg, nil
}
func (r *Runner) TLSConfig() *tls.Config {
tlsConfig := &tls.Config{InsecureSkipVerify: r.Insecure}
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
if err != nil || info.SupportsCertificate(&pair) != nil {
return &tls.Certificate{}, err
}
return &pair, nil
}
}
return tlsConfig
}
// expandEnvironment replaces ${NAME} in values and retypes them, so ${CAPACITY} can be an int and an empty value is unset.
func expandEnvironment(node *yaml.Node) error {
var errs []error
+50
View File
@@ -4,6 +4,13 @@
package config
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
@@ -42,6 +49,49 @@ cache:
require.NoError(t, err)
}
func TestClientCertIsValidatedAndAbsoluteOnLoadReadOnUseAndOfferedOnlyWhenAccepted(t *testing.T) {
require.True(t, (&Runner{Insecure: true}).TLSConfig().InsecureSkipVerify)
dir := t.TempDir()
certPath, keyPath := filepath.Join(dir, "client.crt"), filepath.Join(dir, "client.key")
writePair := func() []byte {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
template := &x509.Certificate{SerialNumber: big.NewInt(1)}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
require.NoError(t, err)
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
require.NoError(t, err)
require.NoError(t, os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600))
require.NoError(t, os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0o600))
return der
}
load := func(certFile, keyFile string) (*Config, error) {
return LoadDefault(write(t, "runner:\n client_cert_file: '"+certFile+"'\n client_key_file: '"+keyFile+"'\n"))
}
writePair()
_, err := load(certPath, "")
require.ErrorContains(t, err, "must be set together")
_, err = load(keyPath, keyPath)
require.ErrorContains(t, err, "load runner client certificate")
t.Chdir(dir)
cfg, err := load("client.crt", "client.key")
require.NoError(t, err)
require.True(t, filepath.IsAbs(cfg.Runner.ClientCertFile))
require.True(t, filepath.IsAbs(cfg.Runner.ClientKeyFile))
renewed := writePair()
info := &tls.CertificateRequestInfo{Version: tls.VersionTLS13, SignatureSchemes: []tls.SignatureScheme{tls.ECDSAWithP256AndSHA256}}
pair, err := cfg.Runner.TLSConfig().GetClientCertificate(info)
require.NoError(t, err)
require.Equal(t, renewed, pair.Certificate[0])
info.AcceptableCAs = [][]byte{[]byte("another CA")}
pair, err = cfg.Runner.TLSConfig().GetClientCertificate(info)
require.NoError(t, err)
require.Empty(t, pair.Certificate)
}
func TestLoadDefault_ExpandsEnvironmentValues(t *testing.T) {
t.Setenv("RUNNER_CAPACITY", "4")
t.Setenv("RUNNER_LABEL", "linux:host")