mirror of
https://gitea.com/gitea/runner.git
synced 2026-11-09 00:30:28 +00:00
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:
1 parent
e5aa9add02
commit
f963e9cfe8
21 files changed
+298
-202
No files matched your search
@@ -168,7 +168,8 @@ type NewGitCloneExecutorInput struct {
|
|||||||
Quiet bool
|
Quiet bool
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
InsecureSkipTLS bool
|
InsecureSkipTLS bool
|
||||||
|
ClientCertFile, ClientKeyFile string // PEM, absolute as git runs in other directories
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloneIfRequired reports whether an existing local clone was reused.
|
// CloneIfRequired reports whether an existing local clone was reused.
|
||||||
@@ -367,6 +368,9 @@ func runGit(ctx context.Context, dir string, input *NewGitCloneExecutorInput, ar
|
|||||||
config := []string{"core.autocrlf=false", "core.eol=lf"} // keep action files LF
|
config := []string{"core.autocrlf=false", "core.eol=lf"} // keep action files LF
|
||||||
if input != nil {
|
if input != nil {
|
||||||
config = append(config, RemoteConfig(input.Token, input.InsecureSkipTLS)...)
|
config = append(config, RemoteConfig(input.Token, input.InsecureSkipTLS)...)
|
||||||
|
if input.ClientCertFile != "" {
|
||||||
|
config = append(config, "http.sslCert="+input.ClientCertFile, "http.sslKey="+input.ClientKeyFile)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if dir != "" {
|
if dir != "" {
|
||||||
args = append([]string{"-C", dir}, args...)
|
args = append([]string{"-C", dir}, args...)
|
||||||
|
|||||||
@@ -532,7 +532,7 @@ func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
|
|||||||
"/jobs/" + strconv.FormatInt(jobID, 10) + "/steps/"
|
"/jobs/" + strconv.FormatInt(jobID, 10) + "/steps/"
|
||||||
actPath := rc.JobContainer.GetActPath()
|
actPath := rc.JobContainer.GetActPath()
|
||||||
// Reuse a single client across all step uploads so connections can be pooled.
|
// Reuse a single client across all step uploads so connections can be pooled.
|
||||||
client := &http.Client{Timeout: jobSummaryUploadRequestTimeout}
|
client := &http.Client{Timeout: jobSummaryUploadRequestTimeout, Transport: rc.Config.instanceTransportFor(base)}
|
||||||
for i := range rc.Run.Job().Steps {
|
for i := range rc.Run.Job().Steps {
|
||||||
summaryPath := path.Join(actPath, "workflow", "step-summary-"+strconv.Itoa(i)+".md")
|
summaryPath := path.Join(actPath, "workflow", "step-summary-"+strconv.Itoa(i)+".md")
|
||||||
body, ok := readSingleFileFromContainerArchive(ctx, rc.JobContainer, summaryPath, maxJobSummaryBytes)
|
body, ok := readSingleFileFromContainerArchive(ctx, rc.JobContainer, summaryPath, maxJobSummaryBytes)
|
||||||
@@ -544,6 +544,15 @@ func tryUploadJobSummary(ctx context.Context, rc *RunContext) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For Gitea
|
||||||
|
// instanceTransportFor returns InstanceTransport only for URLs on the registered address, as steps can rewrite the summary URL through GITHUB_ENV.
|
||||||
|
func (c Config) instanceTransportFor(rawURL string) http.RoundTripper {
|
||||||
|
if !strings.HasPrefix(rawURL, c.GitHubInstance+"/") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.InstanceTransport
|
||||||
|
}
|
||||||
|
|
||||||
// extractJobIDFromRuntimeToken returns the JobID claim from an ACTIONS_RUNTIME_TOKEN JWT
|
// extractJobIDFromRuntimeToken returns the JobID claim from an ACTIONS_RUNTIME_TOKEN JWT
|
||||||
// without verifying its signature. Returns 0 if the token is unparseable or has no JobID.
|
// without verifying its signature. Returns 0 if the token is unparseable or has no JobID.
|
||||||
func extractJobIDFromRuntimeToken(token string) int64 {
|
func extractJobIDFromRuntimeToken(token string) int64 {
|
||||||
|
|||||||
@@ -858,7 +858,7 @@ func TestTryUploadJobSummaryStopsAtPhaseTimeout(t *testing.T) {
|
|||||||
cm.AssertExpectations(t)
|
cm.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTryUploadJobSummaryUploadsEachStepIndependently(t *testing.T) {
|
func TestTryUploadJobSummaryUploadsEachStepIndependentlyWithInstanceTransport(t *testing.T) {
|
||||||
runtimeToken := fakeRuntimeToken(34)
|
runtimeToken := fakeRuntimeToken(34)
|
||||||
|
|
||||||
type upload struct {
|
type upload struct {
|
||||||
@@ -866,7 +866,7 @@ func TestTryUploadJobSummaryUploadsEachStepIndependently(t *testing.T) {
|
|||||||
body string
|
body string
|
||||||
}
|
}
|
||||||
var got []upload
|
var got []upload
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
body, err := io.ReadAll(r.Body)
|
body, err := io.ReadAll(r.Body)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
got = append(got, upload{r.URL.Path, string(body)})
|
got = append(got, upload{r.URL.Path, string(body)})
|
||||||
@@ -896,6 +896,7 @@ func TestTryUploadJobSummaryUploadsEachStepIndependently(t *testing.T) {
|
|||||||
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
|
"ACTIONS_RUNTIME_TOKEN": runtimeToken,
|
||||||
"GITEA_RUN_ID": "12",
|
"GITEA_RUN_ID": "12",
|
||||||
}, cm, 3)
|
}, cm, 3)
|
||||||
|
rc.Config.GitHubInstance, rc.Config.InstanceTransport = server.URL, server.Client().Transport
|
||||||
|
|
||||||
tryUploadJobSummary(ctx, rc)
|
tryUploadJobSummary(ctx, rc)
|
||||||
|
|
||||||
|
|||||||
@@ -39,11 +39,8 @@ func newLocalReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|||||||
|
|
||||||
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(uses))
|
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(uses))
|
||||||
|
|
||||||
// If the repository is private, we need a token to clone it
|
|
||||||
token := rc.Config.GetToken()
|
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir),
|
||||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
|
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -60,10 +57,8 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|||||||
filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Owner, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref)
|
filename := fmt.Sprintf("%s/%s@%s", remoteReusableWorkflow.Owner, remoteReusableWorkflow.Repo, remoteReusableWorkflow.Ref)
|
||||||
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(filename))
|
workflowDir := fmt.Sprintf("%s/%s", rc.ActionCacheDir(), safeFilename(filename))
|
||||||
|
|
||||||
token := getGitCloneToken(rc.Config, remoteReusableWorkflow.CloneURL())
|
|
||||||
|
|
||||||
return common.NewPipelineExecutor(
|
return common.NewPipelineExecutor(
|
||||||
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir, token),
|
cloneRemoteReusableWorkflow(rc, remoteReusableWorkflow.CloneURL(), remoteReusableWorkflow.Ref, workflowDir),
|
||||||
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
|
newReusableWorkflowExecutor(rc, workflowDir, remoteReusableWorkflow.Path),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -76,20 +71,19 @@ func newRemoteReusableWorkflowExecutor(rc *RunContext) common.Executor {
|
|||||||
// 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflow
|
// 2. Gitea has already full URL with rc.Config.GitHubInstance when calling newRemoteReusableWorkflow
|
||||||
//
|
//
|
||||||
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
|
// remoteReusableWorkflow.URL = rc.getGithubContext(ctx).ServerURL
|
||||||
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory, token string) common.Executor {
|
func cloneRemoteReusableWorkflow(rc *RunContext, cloneURL, ref, targetDirectory string) common.Executor {
|
||||||
return func(ctx context.Context) error {
|
return func(ctx context.Context) error {
|
||||||
interpolatedURL, err := rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL)
|
interpolatedURL, err := rc.NewExpressionEvaluator(ctx).Interpolate(ctx, cloneURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to interpolate the workflow clone URL: %w", err)
|
return fmt.Errorf("unable to interpolate the workflow clone URL: %w", err)
|
||||||
}
|
}
|
||||||
return git.NewGitCloneExecutor(git.NewGitCloneExecutorInput{
|
return git.NewGitCloneExecutor(rc.Config.withInstanceAuth(git.NewGitCloneExecutorInput{
|
||||||
URL: interpolatedURL,
|
URL: interpolatedURL,
|
||||||
Ref: ref,
|
Ref: ref,
|
||||||
Dir: targetDirectory,
|
Dir: targetDirectory,
|
||||||
Token: token,
|
|
||||||
OfflineMode: rc.Config.ActionOfflineMode,
|
OfflineMode: rc.Config.ActionOfflineMode,
|
||||||
Depth: rc.Config.ActionCloneDepth,
|
Depth: rc.Config.ActionCloneDepth,
|
||||||
})(ctx)
|
}, rc.Config.GetToken()))(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,13 +202,18 @@ func setReusedWorkflowCallerResult(rc *RunContext, runner *runnerImpl) common.Ex
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
// getGitCloneToken returns GITEA_TOKEN when shouldCloneURLUseToken returns true,
|
// withInstanceAuth passes instance credentials only to clones from this Gitea instance, and runner.insecure only to its registered address.
|
||||||
// otherwise returns an empty string
|
func (c Config) withInstanceAuth(input git.NewGitCloneExecutorInput, token string) git.NewGitCloneExecutorInput {
|
||||||
func getGitCloneToken(conf *Config, cloneURL string) string {
|
u, err := url.Parse(input.URL)
|
||||||
if !shouldCloneURLUseToken(conf.GitHubInstance, conf.trustedActionInstance(), cloneURL) {
|
if err != nil || !onInstanceHost(u.Host, c.GitHubInstance, c.trustedActionInstance()) {
|
||||||
return ""
|
return input
|
||||||
}
|
}
|
||||||
return conf.GetToken()
|
input.ClientCertFile, input.ClientKeyFile = c.ClientCertFile, c.ClientKeyFile
|
||||||
|
input.InsecureSkipTLS = c.InsecureSkipTLS && onInstanceHost(u.Host, c.GitHubInstance)
|
||||||
|
if u.User == nil {
|
||||||
|
input.Token = token
|
||||||
|
}
|
||||||
|
return input
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
@@ -228,26 +227,16 @@ func (c Config) trustedActionInstance() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
// For Gitea
|
||||||
// shouldCloneURLUseToken returns true when the following conditions are met:
|
// onInstanceHost reports whether host is that of one of the instance URLs, which may lack a scheme.
|
||||||
// 1. cloneURL's host matches this Gitea instance: either the registered instance
|
func onInstanceHost(host string, instanceURLs ...string) bool {
|
||||||
// (instanceURL) or, for DEFAULT_ACTIONS_URL=self on a different hostname, the
|
for _, candidate := range instanceURLs {
|
||||||
// self-hosted action instance (trustedActionInstance, "" when not trusted)
|
|
||||||
// 2. the cloneURL does not have basic auth embedded
|
|
||||||
func shouldCloneURLUseToken(instanceURL, trustedActionInstance, cloneURL string) bool {
|
|
||||||
u2, err := url.Parse(cloneURL)
|
|
||||||
if err != nil || u2.User != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, candidate := range []string{instanceURL, trustedActionInstance} {
|
|
||||||
if candidate == "" {
|
if candidate == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(candidate, "http://") &&
|
if !strings.HasPrefix(candidate, "http://") && !strings.HasPrefix(candidate, "https://") {
|
||||||
!strings.HasPrefix(candidate, "https://") {
|
|
||||||
candidate = "https://" + candidate
|
candidate = "https://" + candidate
|
||||||
}
|
}
|
||||||
if u1, err := url.Parse(candidate); err == nil && u1.Host == u2.Host {
|
if u, err := url.Parse(candidate); err == nil && u.Host == host {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ package runner
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -59,7 +64,7 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
cacheDir := t.TempDir()
|
cacheDir := t.TempDir()
|
||||||
|
|
||||||
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir, "")(context.Background()))
|
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir)(context.Background()))
|
||||||
got, err := os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
got, err := os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, tmpl("v1"), string(got))
|
require.Equal(t, tmpl("v1"), string(got))
|
||||||
@@ -69,7 +74,7 @@ func TestReusableWorkflowCachedBranchRefRefreshes(t *testing.T) {
|
|||||||
gitMust(t, workDir, "commit", "-am", "v2")
|
gitMust(t, workDir, "commit", "-am", "v2")
|
||||||
gitMust(t, workDir, "push", "origin", "master")
|
gitMust(t, workDir, "push", "origin", "master")
|
||||||
|
|
||||||
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir, "")(context.Background()))
|
require.NoError(t, cloneRemoteReusableWorkflow(rc, remoteDir, "master", cacheDir)(context.Background()))
|
||||||
got, err = os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
got, err = os.ReadFile(filepath.Join(cacheDir, workflowPath))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, tmpl("v2"), string(got), "cached workflow file must reflect the updated branch tip")
|
require.Equal(t, tmpl("v2"), string(got), "cached workflow file must reflect the updated branch tip")
|
||||||
@@ -123,107 +128,79 @@ func TestNewLocalReusableWorkflowExecutorFindsSameRepositoryPaths(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetGitCloneTokenWithSchemalessGiteaInstance(t *testing.T) {
|
func TestCloneRemoteReusableWorkflowSendsInstanceTokenAndClientCert(t *testing.T) {
|
||||||
conf := &Config{
|
var gotCert bool
|
||||||
GitHubInstance: "gitea.example.net",
|
var gotToken string
|
||||||
Secrets: map[string]string{
|
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
"GITEA_TOKEN": "token-value",
|
gotCert = len(r.TLS.PeerCertificates) > 0
|
||||||
|
_, gotToken, _ = r.BasicAuth()
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
server.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert}
|
||||||
|
server.StartTLS()
|
||||||
|
defer server.Close()
|
||||||
|
pair := server.TLS.Certificates[0]
|
||||||
|
keyDER, err := x509.MarshalPKCS8PrivateKey(pair.PrivateKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
dir := t.TempDir()
|
||||||
|
certFile, keyFile := filepath.Join(dir, "client.crt"), filepath.Join(dir, "client.key")
|
||||||
|
require.NoError(t, os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: pair.Certificate[0]}), 0o600))
|
||||||
|
require.NoError(t, os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}), 0o600))
|
||||||
|
rc := &RunContext{
|
||||||
|
Config: &Config{
|
||||||
|
GitHubInstance: server.URL,
|
||||||
|
Secrets: map[string]string{"GITEA_TOKEN": "token"},
|
||||||
|
InsecureSkipTLS: true,
|
||||||
|
ClientCertFile: certFile,
|
||||||
|
ClientKeyFile: keyFile,
|
||||||
},
|
},
|
||||||
|
Run: &model.Run{JobID: "j1", Workflow: &model.Workflow{Jobs: map[string]*model.Job{"j1": {}}}},
|
||||||
}
|
}
|
||||||
|
|
||||||
token := getGitCloneToken(conf, "https://gitea.example.net/actions/tools")
|
require.Error(t, cloneRemoteReusableWorkflow(rc, server.URL+"/owner/repo", "main", t.TempDir())(t.Context()))
|
||||||
|
require.True(t, gotCert)
|
||||||
require.Equal(t, "token-value", token)
|
require.Equal(t, "token", gotToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetGitCloneTokenSelfHostedActionsDifferentHost(t *testing.T) {
|
func TestInstanceAuthAndTransportOnlyApplyToTheInstance(t *testing.T) {
|
||||||
// The runner registered with one hostname while DEFAULT_ACTIONS_URL=self resolves
|
for _, tt := range []struct {
|
||||||
// actions against AppURL on a different hostname for the same instance.
|
name, instanceURL, trustedActionInstance, cloneURL string
|
||||||
conf := &Config{
|
token, cert, insecure bool
|
||||||
GitHubInstance: "gitea.local",
|
|
||||||
DefaultActionInstance: "https://gitea.my-nas.lan",
|
|
||||||
DefaultActionInstanceIsSelfHosted: true,
|
|
||||||
Secrets: map[string]string{
|
|
||||||
"GITEA_TOKEN": "token-value",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
token := getGitCloneToken(conf, "https://gitea.my-nas.lan/owner/action")
|
|
||||||
|
|
||||||
require.Equal(t, "token-value", token)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestShouldCloneURLUseToken(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
instanceURL string
|
|
||||||
trustedActionInstance string
|
|
||||||
cloneURL string
|
|
||||||
want bool
|
|
||||||
}{
|
}{
|
||||||
{
|
{"same host with schemaless instance", "gitea.example.net", "", "https://gitea.example.net/actions/tools", true, true, true},
|
||||||
name: "same host with schemaless instance",
|
{"same host with schemaless instance and port", "gitea.example.net:3000", "", "https://gitea.example.net:3000/actions/tools", true, true, true},
|
||||||
instanceURL: "gitea.example.net",
|
{"different host", "gitea.example.net", "", "https://github.com/actions/tools", false, false, false},
|
||||||
cloneURL: "https://gitea.example.net/actions/tools",
|
{"embedded basic auth keeps its own credentials", "gitea.example.net", "", "https://user:pass@gitea.example.net/actions/tools", false, true, true},
|
||||||
want: true,
|
{"invalid clone URL", "gitea.example.net", "", "://gitea.example.net/actions/tools", false, false, false},
|
||||||
},
|
{"self-hosted action instance on different host skips no verification", "gitea.local", "https://gitea.my-nas.lan", "https://gitea.my-nas.lan/owner/action", true, true, false},
|
||||||
{
|
{"self-hosted action instance with embedded basic auth", "gitea.local", "https://gitea.my-nas.lan", "https://user:[email protected]/owner/action", false, true, false},
|
||||||
name: "same host with schemaless instance and port",
|
{"github mode does not trust mirror host", "gitea.local", "", "https://mirror.example.com/owner/action", false, false, false},
|
||||||
instanceURL: "gitea.example.net:3000",
|
} {
|
||||||
cloneURL: "https://gitea.example.net:3000/actions/tools",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "different host",
|
|
||||||
instanceURL: "gitea.example.net",
|
|
||||||
cloneURL: "https://github.com/actions/tools",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "embedded basic auth",
|
|
||||||
instanceURL: "gitea.example.net",
|
|
||||||
cloneURL: "https://user:[email protected]/actions/tools",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid clone URL",
|
|
||||||
instanceURL: "gitea.example.net",
|
|
||||||
cloneURL: "://gitea.example.net/actions/tools",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// self-hosted DEFAULT_ACTIONS_URL on a different hostname than the
|
|
||||||
// registered instance: the token must still be attached.
|
|
||||||
name: "self-hosted action instance on different host",
|
|
||||||
instanceURL: "gitea.local",
|
|
||||||
trustedActionInstance: "https://gitea.my-nas.lan",
|
|
||||||
cloneURL: "https://gitea.my-nas.lan/owner/action",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// embedded basic auth must still be rejected even when the host matches
|
|
||||||
// the trusted action instance.
|
|
||||||
name: "self-hosted action instance with embedded basic auth",
|
|
||||||
instanceURL: "gitea.local",
|
|
||||||
trustedActionInstance: "https://gitea.my-nas.lan",
|
|
||||||
cloneURL: "https://user:[email protected]/owner/action",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// github.com / mirror hosts are never trusted: trustedActionInstance is
|
|
||||||
// empty in github mode, so an off-instance clone URL gets no token.
|
|
||||||
name: "github mode does not trust mirror host",
|
|
||||||
instanceURL: "gitea.local",
|
|
||||||
cloneURL: "https://mirror.example.com/owner/action",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
require.Equal(t, tt.want, shouldCloneURLUseToken(tt.instanceURL, tt.trustedActionInstance, tt.cloneURL))
|
conf := Config{
|
||||||
|
GitHubInstance: tt.instanceURL,
|
||||||
|
DefaultActionInstance: tt.trustedActionInstance,
|
||||||
|
DefaultActionInstanceIsSelfHosted: tt.trustedActionInstance != "",
|
||||||
|
InsecureSkipTLS: true,
|
||||||
|
ClientCertFile: "/cert",
|
||||||
|
ClientKeyFile: "/key",
|
||||||
|
}
|
||||||
|
want := git.NewGitCloneExecutorInput{URL: tt.cloneURL, InsecureSkipTLS: tt.insecure}
|
||||||
|
if tt.token {
|
||||||
|
want.Token = "token"
|
||||||
|
}
|
||||||
|
if tt.cert {
|
||||||
|
want.ClientCertFile, want.ClientKeyFile = conf.ClientCertFile, conf.ClientKeyFile
|
||||||
|
}
|
||||||
|
require.Equal(t, want, conf.withInstanceAuth(git.NewGitCloneExecutorInput{URL: tt.cloneURL}, "token"))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
transport := &http.Transport{}
|
||||||
|
conf := Config{GitHubInstance: "https://gitea.local", InstanceTransport: transport}
|
||||||
|
require.Same(t, transport, conf.instanceTransportFor("https://gitea.local/api/actions_pipeline/"))
|
||||||
|
require.Nil(t, conf.instanceTransportFor("https://gitea.local.example/api/actions_pipeline/"))
|
||||||
|
require.Nil(t, conf.instanceTransportFor("https://other.example/x#/api/actions_pipeline/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func gitMust(t *testing.T, dir string, args ...string) {
|
func gitMust(t *testing.T, dir string, args ...string) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package runner
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -77,10 +78,12 @@ type Config struct {
|
|||||||
DefaultActionInstanceIsSelfHosted bool
|
DefaultActionInstanceIsSelfHosted bool
|
||||||
PlatformPicker func(labels []string) string
|
PlatformPicker func(labels []string) string
|
||||||
KubernetesPicker func(labels []string) container.KubernetesOptions
|
KubernetesPicker func(labels []string) container.KubernetesOptions
|
||||||
|
InstanceTransport http.RoundTripper
|
||||||
JobLoggerLevel *log.Level // the level of job logger
|
JobLoggerLevel *log.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
|
ValidVolumes []string // only volumes (and bind mounts) in this slice can be mounted on the job container or service containers
|
||||||
SharedToolCache bool // one tool cache for all jobs instead of one per job
|
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
|
InsecureSkipTLS bool // whether to skip verifying TLS certificate of the Gitea instance
|
||||||
|
ClientCertFile, ClientKeyFile string // PEM client certificate for the Gitea instance
|
||||||
MaxParallel int // max parallel jobs to run across all workflows (0 = no limit, uses CPU count)
|
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
|
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)
|
ServiceReadyTimeout time.Duration // how long a job waits for its service containers to report healthy (0 uses the default)
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ func (sab *stepActionBuiltin) main() common.Executor {
|
|||||||
NoSkipCheckout: rc.Config.NoSkipCheckout,
|
NoSkipCheckout: rc.Config.NoSkipCheckout,
|
||||||
UseGitIgnore: rc.Config.UseGitIgnore,
|
UseGitIgnore: rc.Config.UseGitIgnore,
|
||||||
InsecureSkipTLS: rc.Config.InsecureSkipTLS,
|
InsecureSkipTLS: rc.Config.InsecureSkipTLS,
|
||||||
|
ClientCertFile: rc.Config.ClientCertFile,
|
||||||
|
ClientKeyFile: rc.Config.ClientKeyFile,
|
||||||
Env: sab.env,
|
Env: sab.env,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -67,27 +67,16 @@ func (sar *stepActionRemote) prepareActionExecutor() common.Executor {
|
|||||||
actionDir := sar.actionDir()
|
actionDir := sar.actionDir()
|
||||||
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
|
defaultActionURL := sar.RunContext.Config.DefaultActionURL()
|
||||||
// For Gitea
|
// For Gitea
|
||||||
// A composite RunContext nils Config.Secrets, so getGitCloneToken would yield an
|
// github.Token, unlike Config.GetToken, survives the composite config copy, which nils Config.Secrets.
|
||||||
// empty token and clone the action anonymously (401 against the authenticated
|
gitClone := stepActionRemoteNewCloneExecutor(sar.RunContext.Config.withInstanceAuth(git.NewGitCloneExecutorInput{
|
||||||
// instance). github.Token survives the composite config copy and matches the
|
URL: sar.remoteAction.CloneURL(defaultActionURL),
|
||||||
// top-level token; keep the shouldCloneURLUseToken host gate to avoid leaking it.
|
|
||||||
cloneURL := sar.remoteAction.CloneURL(defaultActionURL)
|
|
||||||
token := ""
|
|
||||||
if shouldCloneURLUseToken(sar.RunContext.Config.GitHubInstance, sar.RunContext.Config.trustedActionInstance(), cloneURL) {
|
|
||||||
token = github.Token
|
|
||||||
}
|
|
||||||
gitClone := stepActionRemoteNewCloneExecutor(git.NewGitCloneExecutorInput{
|
|
||||||
URL: cloneURL,
|
|
||||||
Ref: sar.remoteAction.Ref,
|
Ref: sar.remoteAction.Ref,
|
||||||
Dir: actionDir,
|
Dir: actionDir,
|
||||||
Token: token,
|
|
||||||
OfflineMode: sar.RunContext.Config.ActionOfflineMode,
|
OfflineMode: sar.RunContext.Config.ActionOfflineMode,
|
||||||
Depth: sar.RunContext.Config.ActionCloneDepth,
|
Depth: sar.RunContext.Config.ActionCloneDepth,
|
||||||
// printPrepareActions reports the download with its resolved commit.
|
// printPrepareActions reports the download with its resolved commit.
|
||||||
Quiet: true,
|
Quiet: true,
|
||||||
|
}, github.Token))
|
||||||
InsecureSkipTLS: sar.cloneSkipTLS(), // For Gitea
|
|
||||||
})
|
|
||||||
if err := gitClone(ctx); err != nil {
|
if err := gitClone(ctx); err != nil {
|
||||||
var refErr *git.Error
|
var refErr *git.Error
|
||||||
switch {
|
switch {
|
||||||
@@ -250,22 +239,6 @@ func (sar *stepActionRemote) getCompositeSteps() *compositeSteps {
|
|||||||
return sar.compositeSteps
|
return sar.compositeSteps
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Gitea
|
|
||||||
// cloneSkipTLS returns true if the runner can clone an action from the Gitea instance
|
|
||||||
func (sar *stepActionRemote) cloneSkipTLS() bool {
|
|
||||||
if !sar.RunContext.Config.InsecureSkipTLS {
|
|
||||||
// Return false if the Gitea instance is not an insecure instance
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if sar.remoteAction.URL == "" {
|
|
||||||
// Empty URL means the default action instance should be used
|
|
||||||
// Return true if the URL of the Gitea instance is the same as the URL of the default action instance
|
|
||||||
return sar.RunContext.Config.DefaultActionURL() == sar.RunContext.Config.GitHubInstance
|
|
||||||
}
|
|
||||||
// Return true if the URL of the remote action is the same as the URL of the Gitea instance
|
|
||||||
return sar.remoteAction.URL == sar.RunContext.Config.GitHubInstance
|
|
||||||
}
|
|
||||||
|
|
||||||
type remoteAction struct {
|
type remoteAction struct {
|
||||||
URL string
|
URL string
|
||||||
Org string
|
Org string
|
||||||
|
|||||||
@@ -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`.
|
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`. 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. 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.
|
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.
|
||||||
@@ -75,7 +75,7 @@ func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *p
|
|||||||
}
|
}
|
||||||
labelNames := []string{label.Name}
|
labelNames := []string{label.Name}
|
||||||
|
|
||||||
pingCli := client.New(fixture.baseURL, cfg.Runner.Insecure, "", "", config.RequestTimeout, cfg.Runner.ExtraHeaders)
|
pingCli := client.New(fixture.baseURL, cfg.Runner.TLSConfig(), "", "", config.RequestTimeout, cfg.Runner.ExtraHeaders)
|
||||||
if _, err := pingCli.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{Data: t.Name()})); err != nil {
|
if _, err := pingCli.Ping(ctx, connect.NewRequest(&pingv1.PingRequest{Data: t.Name()})); err != nil {
|
||||||
t.Fatalf("ping %s: %v", fixture.baseURL, err)
|
t.Fatalf("ping %s: %v", fixture.baseURL, err)
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ func startRunner(t *testing.T, repo, labelName string, options runnerOptions) *p
|
|||||||
Ephemeral: regResp.Msg.Runner.Ephemeral,
|
Ephemeral: regResp.Msg.Runner.Ephemeral,
|
||||||
}
|
}
|
||||||
|
|
||||||
cli := client.New(fixture.baseURL, cfg.Runner.Insecure, reg.UUID, reg.Token, config.RequestTimeout, cfg.Runner.ExtraHeaders)
|
cli := client.New(fixture.baseURL, cfg.Runner.TLSConfig(), reg.UUID, reg.Token, config.RequestTimeout, cfg.Runner.ExtraHeaders)
|
||||||
|
|
||||||
runner := run.NewRunner(cfg, reg, cli)
|
runner := run.NewRunner(cfg, reg, cli)
|
||||||
declResp, err := runner.Declare(ctx, labelNames)
|
declResp, err := runner.Declare(ctx, labelNames)
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ func runDaemon(ctx context.Context, daemArgs *daemonArgs, configFile *string) fu
|
|||||||
|
|
||||||
cli := client.New(
|
cli := client.New(
|
||||||
reg.Address,
|
reg.Address,
|
||||||
cfg.Runner.Insecure,
|
cfg.Runner.TLSConfig(),
|
||||||
reg.UUID,
|
reg.UUID,
|
||||||
reg.Token,
|
reg.Token,
|
||||||
config.RequestTimeout,
|
config.RequestTimeout,
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ func doRegister(ctx context.Context, cfg *config.Config, inputs *registerInputs)
|
|||||||
// initial http client
|
// initial http client
|
||||||
cli := client.New(
|
cli := client.New(
|
||||||
inputs.InstanceAddr,
|
inputs.InstanceAddr,
|
||||||
cfg.Runner.Insecure,
|
cfg.Runner.TLSConfig(),
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
config.RequestTimeout,
|
config.RequestTimeout,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ type Runner struct {
|
|||||||
capabilities string
|
capabilities string
|
||||||
|
|
||||||
isolatedCacheContainer func() string
|
isolatedCacheContainer func() string
|
||||||
|
instanceTransport http.RoundTripper
|
||||||
|
|
||||||
runningTasks sync.Map
|
runningTasks sync.Map
|
||||||
runningCount atomic.Int64
|
runningCount atomic.Int64
|
||||||
@@ -140,6 +141,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client)
|
|||||||
runHealthCheck: executeHealthCheck,
|
runHealthCheck: executeHealthCheck,
|
||||||
}
|
}
|
||||||
runner.isolatedCacheContainer = sync.OnceValue(runner.detectIsolatedCacheContainer)
|
runner.isolatedCacheContainer = sync.OnceValue(runner.detectIsolatedCacheContainer)
|
||||||
|
runner.instanceTransport = client.NewTransport(cli.Address(), cfg.Runner.TLSConfig())
|
||||||
return runner
|
return runner
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,6 +628,9 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report.
|
|||||||
ValidVolumes: r.cfg.Container.ValidVolumes,
|
ValidVolumes: r.cfg.Container.ValidVolumes,
|
||||||
SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared,
|
SharedToolCache: r.cfg.Runner.ToolCacheMode == config.ToolCacheModeShared,
|
||||||
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
InsecureSkipTLS: r.cfg.Runner.Insecure,
|
||||||
|
ClientCertFile: r.cfg.Runner.ClientCertFile,
|
||||||
|
ClientKeyFile: r.cfg.Runner.ClientKeyFile,
|
||||||
|
InstanceTransport: r.instanceTransport,
|
||||||
RunnerName: r.name,
|
RunnerName: r.name,
|
||||||
KubernetesPicker: r.kubernetesOptions,
|
KubernetesPicker: r.kubernetesOptions,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,5 +20,7 @@ type Context struct {
|
|||||||
NoSkipCheckout bool
|
NoSkipCheckout bool
|
||||||
UseGitIgnore bool
|
UseGitIgnore bool
|
||||||
InsecureSkipTLS bool
|
InsecureSkipTLS bool
|
||||||
|
ClientCertFile string
|
||||||
|
ClientKeyFile string
|
||||||
Env map[string]string
|
Env map[string]string
|
||||||
}
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -122,6 +123,26 @@ func Main(ctx context.Context, c *action.Context) (err error) {
|
|||||||
if boolInput(c.Inputs, "set-safe-directory", true) {
|
if boolInput(c.Inputs, "set-safe-directory", true) {
|
||||||
remoteConfig = append(remoteConfig, "safe.directory=*") // a bound workdir belongs to the host user
|
remoteConfig = append(remoteConfig, "safe.directory=*") // a bound workdir belongs to the host user
|
||||||
}
|
}
|
||||||
|
if c.ClientCertFile != "" { // only for this step, later steps never get the runner's key
|
||||||
|
var certPEM, keyPEM []byte
|
||||||
|
if certPEM, err = os.ReadFile(c.ClientCertFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if keyPEM, err = os.ReadFile(c.ClientKeyFile); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cert, key := fmt.Sprintf("git-client-cert-%x.pem", digest[:8]), fmt.Sprintf("git-client-key-%x.pem", digest[:8])
|
||||||
|
defer func() {
|
||||||
|
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
err = errors.Join(err, c.Container.Copy(runnerTemp, &container.FileEntry{Name: key, Mode: 0o600})(cleanupCtx))
|
||||||
|
}()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
scope := "http." + strings.TrimSuffix(ghc.ServerURL, "/") + "/."
|
||||||
|
remoteConfig = append(remoteConfig, scope+"sslCert="+path.Join(runnerTemp, cert), scope+"sslKey="+path.Join(runnerTemp, key))
|
||||||
|
}
|
||||||
env := gitEnv(c, remoteConfig)
|
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
|
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
|
||||||
if sshKey != "" {
|
if sshKey != "" {
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ package checkout
|
|||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -286,6 +291,41 @@ exec sh -c "$(printf '%s' "$last" | sed "s#git-upload-pack '/\(.*\)\.git'#git up
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckoutOffersClientCertOnlyDuringTheStep(t *testing.T) {
|
||||||
|
var gotCert bool
|
||||||
|
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotCert = len(r.TLS.PeerCertificates) > 0
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
server.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert}
|
||||||
|
server.StartTLS()
|
||||||
|
defer server.Close()
|
||||||
|
pair := server.TLS.Certificates[0]
|
||||||
|
keyDER, err := x509.MarshalPKCS8PrivateKey(pair.PrivateKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
dir, workspace, runnerTemp := t.TempDir(), t.TempDir(), t.TempDir()
|
||||||
|
certFile, keyFile := filepath.Join(dir, "client.crt"), filepath.Join(dir, "client.key")
|
||||||
|
require.NoError(t, os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: pair.Certificate[0]}), 0o600))
|
||||||
|
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"},
|
||||||
|
}), "checkout: other/repo@")
|
||||||
|
require.True(t, gotCert)
|
||||||
|
keys, _ := filepath.Glob(filepath.Join(runnerTemp, "git-client-key-*"))
|
||||||
|
require.Len(t, keys, 1)
|
||||||
|
key, err := os.ReadFile(keys[0])
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, key)
|
||||||
|
}
|
||||||
|
|
||||||
func publish(t *testing.T, server, name string, files, submodules map[string]string) {
|
func publish(t *testing.T, server, name string, files, submodules map[string]string) {
|
||||||
work := t.TempDir()
|
work := t.TempDir()
|
||||||
runGit(t, "init", "--initial-branch=main", work)
|
runGit(t, "init", "--initial-branch=main", work)
|
||||||
|
|||||||
@@ -19,20 +19,23 @@ import (
|
|||||||
"gitea.dev/actionslib/runner/v1/runnerv1connect"
|
"gitea.dev/actionslib/runner/v1/runnerv1connect"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getHTTPClient(endpoint string, insecure bool, timeout time.Duration, extraHeaders map[string]string) *http.Client {
|
func NewTransport(endpoint string, tlsConfig *tls.Config) *http.Transport {
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
Proxy: http.ProxyFromEnvironment,
|
Proxy: http.ProxyFromEnvironment,
|
||||||
MaxIdleConns: 10,
|
MaxIdleConns: 10,
|
||||||
MaxIdleConnsPerHost: 10, // All requests go to one host; default is 2 which causes frequent reconnects.
|
MaxIdleConnsPerHost: 10, // All requests go to one host; default is 2 which causes frequent reconnects.
|
||||||
IdleConnTimeout: 90 * time.Second,
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(endpoint, "https://") && insecure {
|
if strings.HasPrefix(endpoint, "https://") {
|
||||||
transport.TLSClientConfig = &tls.Config{
|
transport.TLSClientConfig = tlsConfig
|
||||||
InsecureSkipVerify: true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return transport
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHTTPClient(endpoint string, tlsConfig *tls.Config, timeout time.Duration, extraHeaders map[string]string) *http.Client {
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Transport: transport,
|
Transport: NewTransport(endpoint, tlsConfig),
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
if len(via) >= 10 { // a custom CheckRedirect replaces net/http's own limit
|
if len(via) >= 10 { // a custom CheckRedirect replaces net/http's own limit
|
||||||
@@ -54,7 +57,7 @@ func getHTTPClient(endpoint string, insecure bool, timeout time.Duration, extraH
|
|||||||
// New returns a new runner client. timeout bounds every RPC: without it a
|
// New returns a new runner client. timeout bounds every RPC: without it a
|
||||||
// stalled connection parks the reporter for the whole job context, so logs and
|
// stalled connection parks the reporter for the whole job context, so logs and
|
||||||
// heartbeats stop together and the task is reaped as a zombie.
|
// heartbeats stop together and the task is reaped as a zombie.
|
||||||
func New(endpoint string, insecure bool, uuid, token string, timeout time.Duration, extraHeaders map[string]string, opts ...connect.ClientOption) *HTTPClient {
|
func New(endpoint string, tlsConfig *tls.Config, uuid, token string, timeout time.Duration, extraHeaders map[string]string, opts ...connect.ClientOption) *HTTPClient {
|
||||||
baseURL := strings.TrimRight(endpoint, "/") + "/api/actions"
|
baseURL := strings.TrimRight(endpoint, "/") + "/api/actions"
|
||||||
|
|
||||||
opts = append(opts, connect.WithInterceptors(connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
|
opts = append(opts, connect.WithInterceptors(connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
|
||||||
@@ -75,7 +78,7 @@ func New(endpoint string, insecure bool, uuid, token string, timeout time.Durati
|
|||||||
}
|
}
|
||||||
})))
|
})))
|
||||||
|
|
||||||
httpClient := getHTTPClient(endpoint, insecure, timeout, extraHeaders)
|
httpClient := getHTTPClient(endpoint, tlsConfig, timeout, extraHeaders)
|
||||||
return &HTTPClient{
|
return &HTTPClient{
|
||||||
PingServiceClient: pingv1connect.NewPingServiceClient(
|
PingServiceClient: pingv1connect.NewPingServiceClient(
|
||||||
httpClient,
|
httpClient,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -20,7 +21,7 @@ import (
|
|||||||
func TestGetHTTPClientUsesProxyFromEnvironment(t *testing.T) {
|
func TestGetHTTPClientUsesProxyFromEnvironment(t *testing.T) {
|
||||||
t.Setenv("HTTP_PROXY", "http://proxy.example.com:8080")
|
t.Setenv("HTTP_PROXY", "http://proxy.example.com:8080")
|
||||||
|
|
||||||
client := getHTTPClient("http://gitea.example.com", false, time.Minute, nil)
|
client := getHTTPClient("http://gitea.example.com", nil, time.Minute, nil)
|
||||||
require.Equal(t, time.Minute, client.Timeout)
|
require.Equal(t, time.Minute, client.Timeout)
|
||||||
transport, ok := client.Transport.(*http.Transport)
|
transport, ok := client.Transport.(*http.Transport)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
@@ -34,29 +35,10 @@ func TestGetHTTPClientUsesProxyFromEnvironment(t *testing.T) {
|
|||||||
require.Equal(t, "http://proxy.example.com:8080", proxyURL.String())
|
require.Equal(t, "http://proxy.example.com:8080", proxyURL.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetHTTPClientInsecureTLS(t *testing.T) {
|
func TestGetHTTPClientAppliesTLSConfigOnlyToHTTPS(t *testing.T) {
|
||||||
// insecure only takes effect for https endpoints
|
tlsConfig := &tls.Config{}
|
||||||
httpsInsecure := getHTTPClient("https://gitea.example.com", true, time.Minute, nil)
|
require.Same(t, tlsConfig, getHTTPClient("https://gitea.example.com", tlsConfig, time.Minute, nil).Transport.(*http.Transport).TLSClientConfig)
|
||||||
transport, ok := httpsInsecure.Transport.(*http.Transport)
|
require.Nil(t, getHTTPClient("http://gitea.example.com", tlsConfig, time.Minute, nil).Transport.(*http.Transport).TLSClientConfig)
|
||||||
require.True(t, ok)
|
|
||||||
require.NotNil(t, transport.TLSClientConfig)
|
|
||||||
require.True(t, transport.TLSClientConfig.InsecureSkipVerify)
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
endpoint string
|
|
||||||
insecure bool
|
|
||||||
}{
|
|
||||||
{"https secure", "https://gitea.example.com", false},
|
|
||||||
{"http insecure ignored", "http://gitea.example.com", true},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
c := getHTTPClient(tc.endpoint, tc.insecure, time.Minute, nil)
|
|
||||||
tr, ok := c.Transport.(*http.Transport)
|
|
||||||
require.True(t, ok)
|
|
||||||
require.Nil(t, tr.TLSClientConfig)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewSetsBaseURLAndHeaders(t *testing.T) {
|
func TestNewSetsBaseURLAndHeaders(t *testing.T) {
|
||||||
@@ -70,7 +52,7 @@ func TestNewSetsBaseURLAndHeaders(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
// trailing slash must be trimmed before "/api/actions" is appended
|
// trailing slash must be trimmed before "/api/actions" is appended
|
||||||
c := New(server.URL+"/", false, "the-uuid", "the-token", time.Minute, map[string]string{
|
c := New(server.URL+"/", nil, "the-uuid", "the-token", time.Minute, map[string]string{
|
||||||
"X-Proxy-Token": "proxy-token",
|
"X-Proxy-Token": "proxy-token",
|
||||||
protocol.UUIDHeader: "other-uuid",
|
protocol.UUIDHeader: "other-uuid",
|
||||||
protocol.TokenHeader: "other-token",
|
protocol.TokenHeader: "other-token",
|
||||||
@@ -97,7 +79,7 @@ func TestNewOmitsEmptyHeaders(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
c := New(server.URL, false, "", "", time.Minute, nil)
|
c := New(server.URL, nil, "", "", time.Minute, nil)
|
||||||
_, _ = c.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Data: "hi"}))
|
_, _ = c.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Data: "hi"}))
|
||||||
|
|
||||||
require.Empty(t, gotHeaders.Get(protocol.UUIDHeader))
|
require.Empty(t, gotHeaders.Get(protocol.UUIDHeader))
|
||||||
@@ -110,7 +92,7 @@ func TestNewStopsRedirectLoop(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
c := New(server.URL, false, "", "", time.Minute, nil)
|
c := New(server.URL, nil, "", "", time.Minute, nil)
|
||||||
_, err := c.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Data: "hi"}))
|
_, err := c.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Data: "hi"}))
|
||||||
require.ErrorContains(t, err, "stopped after 10 redirects")
|
require.ErrorContains(t, err, "stopped after 10 redirects")
|
||||||
}
|
}
|
||||||
@@ -136,7 +118,7 @@ func TestNewRedirectKeepsExtraHeadersOnlyOnSameHost(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
gotHeaders = nil
|
gotHeaders = nil
|
||||||
origin := tc.newOrigin(http.RedirectHandler("http://"+net.JoinHostPort(tc.host, port), http.StatusPermanentRedirect))
|
origin := tc.newOrigin(http.RedirectHandler("http://"+net.JoinHostPort(tc.host, port), http.StatusPermanentRedirect))
|
||||||
c := New(origin.URL, true, "the-uuid", "the-token", time.Minute, map[string]string{"X-Proxy-Token": "proxy-token", "Content-Type": "text/plain"})
|
c := New(origin.URL, &tls.Config{InsecureSkipVerify: true}, "the-uuid", "the-token", time.Minute, map[string]string{"X-Proxy-Token": "proxy-token", "Content-Type": "text/plain"})
|
||||||
_, _ = c.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Data: "hi"}))
|
_, _ = c.Ping(t.Context(), connect.NewRequest(&pingv1.PingRequest{Data: "hi"}))
|
||||||
origin.Close()
|
origin.Close()
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ runner:
|
|||||||
#shutdown_timeout: 0s
|
#shutdown_timeout: 0s
|
||||||
# Whether skip verifying the TLS certificate of the Gitea instance.
|
# Whether skip verifying the TLS certificate of the Gitea instance.
|
||||||
#insecure: false
|
#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.
|
# The timeout for fetching the job from the Gitea instance.
|
||||||
# Values above the 60s RPC timeout are capped to it.
|
# Values above the 60s RPC timeout are capped to it.
|
||||||
#fetch_timeout: 5s
|
#fetch_timeout: 5s
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
@@ -63,6 +64,8 @@ type Runner struct {
|
|||||||
Timeout time.Duration `yaml:"timeout"` // Timeout specifies the duration for runner timeout.
|
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.
|
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.
|
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.
|
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.
|
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.
|
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)
|
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 == "" {
|
if cfg.Log.Level == "" {
|
||||||
cfg.Log.Level = "info"
|
cfg.Log.Level = "info"
|
||||||
@@ -422,6 +440,20 @@ func LoadDefault(file string) (*Config, error) {
|
|||||||
return cfg, nil
|
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.
|
// 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 {
|
func expandEnvironment(node *yaml.Node) error {
|
||||||
var errs []error
|
var errs []error
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -42,6 +49,49 @@ cache:
|
|||||||
require.NoError(t, err)
|
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) {
|
func TestLoadDefault_ExpandsEnvironmentValues(t *testing.T) {
|
||||||
t.Setenv("RUNNER_CAPACITY", "4")
|
t.Setenv("RUNNER_CAPACITY", "4")
|
||||||
t.Setenv("RUNNER_LABEL", "linux:host")
|
t.Setenv("RUNNER_LABEL", "linux:host")
|
||||||
|
|||||||
Reference in new issue
Block a user