diff --git a/README.md b/README.md index e848a679..e2674f12 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ Run one dedicated `gitea-runner cache-server` that all runners point at. dir: /data/actcache port: 8088 external_secret: "replace-with-a-strong-random-secret" + # external_secret_file: /path/to/secret # secret can also be passed via a file ``` 2. Start the server: @@ -238,6 +239,7 @@ Run one dedicated `gitea-runner cache-server` that all runners point at. cache: external_server: "http://:8088/" external_secret: "replace-with-a-strong-random-secret" # must match the server + # external_secret_file: /path/to/secret # secret can also be passed via a file ``` Alternatively, mount the same NFS/CIFS share on every runner and point `cache.dir` at it — simpler, but with weaker isolation between repositories. diff --git a/internal/app/cmd/cache-server.go b/internal/app/cmd/cache-server.go index 168fffef..1d63da05 100644 --- a/internal/app/cmd/cache-server.go +++ b/internal/app/cmd/cache-server.go @@ -50,7 +50,7 @@ func runCacheServer(configFile *string, cacheArgs *cacheServerArgs) func(cmd *co secret := cfg.Cache.ExternalSecret if secret == "" { - return errors.New("cache.external_secret must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server") + return errors.New("cache.external_secret (or cache.external_secret_file) must be set for cache-server; configure the same value on each runner that points at this server via cache.external_server") } cacheHandler, err := artifactcache.StartHandler( dir, diff --git a/internal/app/run/runner.go b/internal/app/run/runner.go index 7dabc3a4..04a5484a 100644 --- a/internal/app/run/runner.go +++ b/internal/app/run/runner.go @@ -89,6 +89,7 @@ func NewRunner(cfg *config.Config, reg *config.Registration, cli client.Client) if cfg.Cache.ExternalServer != "" { envs["ACTIONS_CACHE_URL"] = cfg.Cache.ExternalServer } else { + warnIgnoredCacheSecret(cfg) handler, err := artifactcache.StartHandler( cfg.Cache.Dir, cfg.Cache.Host, @@ -512,14 +513,11 @@ func (r *Runner) run(ctx context.Context, task *runnerv1.Task, reporter *report. // function the caller must invoke (typically via defer) to revoke the // credential when the task finishes. // -// Three modes: +// Two modes: // - Embedded handler: register in-process via RegisterJob. -// - external_server + external_secret: POST to the remote server's -// /_internal/register, defer a POST to /_internal/revoke. This is what -// enables full per-job auth and repo scoping over the network. -// - external_server alone (no secret): no-op revoker. The remote server is -// in legacy openMode and ignores the runtime token; trust is at the -// network layer. +// - external_server: POST to the remote server's /_internal/register, defer a +// POST to /_internal/revoke. This is what enables full per-job auth and +// repo scoping over the network. // // Safe with an empty token (older Gitea did not issue one). func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Reporter) func() { @@ -532,6 +530,7 @@ func (r *Runner) registerCacheForTask(token, repo string, reporter *report.Repor if r.cfg.Cache.ExternalServer != "" && r.cfg.Cache.ExternalSecret != "" { return r.registerExternalCacheJob(token, repo, reporter) } + // No cache server to register against: caching is disabled, or the built-in server failed to start. return func() {} } @@ -655,3 +654,20 @@ func (r *Runner) Declare(ctx context.Context, labels []string) (*connect.Respons Capabilities: RunnerCapabilities(), })) } + +// warnIgnoredCacheSecret flags an external cache server secret configured on a runner that uses the built-in cache server. +func warnIgnoredCacheSecret(cfg *config.Config) { + if cfg.Cache.ExternalServer != "" { + return + } + // Not using an external cache server, so any configured secret is ignored. + if cfg.Cache.ExternalSecret == "" { + return + } + // LoadDefault resolves external_secret_file into ExternalSecret, so report whichever key the operator actually wrote. + key := "cache.external_secret" + if cfg.Cache.ExternalSecretFile != "" { + key = "cache.external_secret_file" + } + log.Warnf("%s is set but cache.external_server is not; the built-in cache server does not use a shared secret, so the value is ignored", key) +} diff --git a/internal/pkg/config/config.example.yaml b/internal/pkg/config/config.example.yaml index 0abee234..d463fed6 100644 --- a/internal/pkg/config/config.example.yaml +++ b/internal/pkg/config/config.example.yaml @@ -132,6 +132,11 @@ cache: # Required when external_server is set. Must be identical on every runner and the cache-server. # Generate with: openssl rand -hex 32 external_secret: "" + # Path to a file containing the shared secret, as an alternative to external_secret. + # Use this to keep the secret out of this file. + # Surrounding whitespace is trimmed, so a trailing newline in the file is fine. + # Setting both external_secret and external_secret_file is an error. + external_secret_file: "" # When true, reuse a cached action instead of fetching from the remote on every job. # A moved tag (e.g. a re-tagged "v6") or an updated branch stays at the cached commit # until its cache entry expires or is manually removed. diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index c56b244e..77ac253e 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -9,6 +9,7 @@ import ( "maps" "os" "path/filepath" + "strings" "time" "github.com/joho/godotenv" @@ -57,13 +58,14 @@ type Runner struct { // Cache represents the configuration for caching. type Cache struct { - Enabled *bool `yaml:"enabled"` // Enabled indicates whether caching is enabled. It is a pointer to distinguish between false and not set. If not set, it will be true. - Dir string `yaml:"dir"` // Dir specifies the directory path for caching. - Host string `yaml:"host"` // Host specifies the caching host. - Port uint16 `yaml:"port"` // Port specifies the caching port. - ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server - ExternalSecret string `yaml:"external_secret"` // ExternalSecret is a shared secret between this runner and an external gitea-runner cache-server, enabling per-job ACTIONS_RUNTIME_TOKEN authentication and repo scoping over the network. Leave empty to keep the legacy unauthenticated behavior. - OfflineMode bool `yaml:"offline_mode"` // OfflineMode reuses a cached action without fetching from the remote; a moved tag or branch stays at the cached commit until the cache entry is removed. + Enabled *bool `yaml:"enabled"` // Enabled indicates whether caching is enabled. It is a pointer to distinguish between false and not set. If not set, it will be true. + Dir string `yaml:"dir"` // Dir specifies the directory path for caching. + Host string `yaml:"host"` // Host specifies the caching host. + Port uint16 `yaml:"port"` // Port specifies the caching port. + ExternalServer string `yaml:"external_server"` // ExternalServer specifies the URL of external cache server + ExternalSecret string `yaml:"external_secret"` // ExternalSecret is a shared secret between this runner and an external gitea-runner cache-server, enabling per-job ACTIONS_RUNTIME_TOKEN authentication and repo scoping over the network. Required whenever ExternalServer is set; ExternalSecretFile is the alternative way to provide it. + ExternalSecretFile string `yaml:"external_secret_file"` // ExternalSecretFile is the path to a file holding the ExternalSecret value, so the secret can be mounted instead of stored in the config file. LoadDefault reads it into ExternalSecret; setting both is an error. + OfflineMode bool `yaml:"offline_mode"` // OfflineMode reuses a cached action without fetching from the remote; a moved tag or branch stays at the cached commit until the cache entry is removed. } // Container represents the configuration for the container. @@ -177,6 +179,10 @@ func LoadDefault(file string) (*Config, error) { b := true cfg.Cache.Enabled = &b } + // Resolved regardless of cache.enabled, because the `cache-server` command reads the secret from the same key without checking cache.enabled. + if err := resolveCacheExternalSecret(cfg); err != nil { + return nil, err + } if *cfg.Cache.Enabled { if cfg.Cache.Dir == "" { home, err := os.UserHomeDir() @@ -186,7 +192,7 @@ func LoadDefault(file string) (*Config, error) { cfg.Cache.Dir = filepath.Join(home, ".cache", "actcache") } if cfg.Cache.ExternalServer != "" && cfg.Cache.ExternalSecret == "" { - return nil, errors.New("cache.external_server is set but cache.external_secret is empty; configure the same external_secret on this runner and the gitea-runner cache-server") + return nil, errors.New("cache.external_server is set but no shared secret is configured; set cache.external_secret (or cache.external_secret_file) to the same value used by the gitea-runner cache-server") } } if cfg.Container.WorkdirParent == "" { @@ -301,3 +307,24 @@ func definedRunnerConfigKeys(content []byte) (map[string]bool, error) { return defined, nil } + +// resolveCacheExternalSecret loads cache.external_secret from the file named by cache.external_secret_file, +// so deployments can mount the secret instead of committing it to the config file. +func resolveCacheExternalSecret(cfg *Config) error { + if cfg.Cache.ExternalSecretFile == "" { + return nil + } + if cfg.Cache.ExternalSecret != "" { + return errors.New("cache.external_secret and cache.external_secret_file are both set; configure only one of them") + } + content, err := os.ReadFile(cfg.Cache.ExternalSecretFile) + if err != nil { + return fmt.Errorf("read cache.external_secret_file %q: %w", cfg.Cache.ExternalSecretFile, err) + } + secret := strings.TrimSpace(string(content)) + if secret == "" { + return fmt.Errorf("cache.external_secret_file %q contains no secret", cfg.Cache.ExternalSecretFile) + } + cfg.Cache.ExternalSecret = secret + return nil +} diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go index f5aaa758..e3c75dcf 100644 --- a/internal/pkg/config/config_test.go +++ b/internal/pkg/config/config_test.go @@ -227,3 +227,91 @@ func TestContainerNetworkCreateOptions(t *testing.T) { assert.Nil(t, opts.EnableIPv6) }) } + +func TestLoadDefault_ReadsExternalSecretFromFile(t *testing.T) { + dir := t.TempDir() + secretPath := filepath.Join(dir, "cache.secret") + require.NoError(t, os.WriteFile(secretPath, []byte(" s3cr3t\n"), 0o600)) + + path := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +cache: + enabled: true + external_server: "http://cache.invalid/" + external_secret_file: "`+secretPath+`" +`), 0o600)) + + cfg, err := LoadDefault(path) + require.NoError(t, err) + assert.Equal(t, "s3cr3t", cfg.Cache.ExternalSecret) +} + +func TestLoadDefault_ReadsExternalSecretFromFileWhenCacheDisabled(t *testing.T) { + dir := t.TempDir() + secretPath := filepath.Join(dir, "cache.secret") + require.NoError(t, os.WriteFile(secretPath, []byte("s3cr3t"), 0o600)) + + // the file has to be resolved even when cache is disabled + path := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +cache: + enabled: false + external_secret_file: "`+secretPath+`" +`), 0o600)) + + cfg, err := LoadDefault(path) + require.NoError(t, err) + assert.Equal(t, "s3cr3t", cfg.Cache.ExternalSecret) +} + +func TestLoadDefault_RejectsBothExternalSecretAndFile(t *testing.T) { + dir := t.TempDir() + secretPath := filepath.Join(dir, "cache.secret") + require.NoError(t, os.WriteFile(secretPath, []byte("s3cr3t"), 0o600)) + + path := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +cache: + enabled: true + external_server: "http://cache.invalid/" + external_secret: "inline" + external_secret_file: "`+secretPath+`" +`), 0o600)) + + _, err := LoadDefault(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "both set") +} + +func TestLoadDefault_RejectsMissingExternalSecretFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +cache: + enabled: true + external_server: "http://cache.invalid/" + external_secret_file: "`+filepath.Join(dir, "absent.secret")+`" +`), 0o600)) + + _, err := LoadDefault(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "read cache.external_secret_file") +} + +func TestLoadDefault_RejectsEmptyExternalSecretFile(t *testing.T) { + dir := t.TempDir() + secretPath := filepath.Join(dir, "cache.secret") + require.NoError(t, os.WriteFile(secretPath, []byte("\n \n"), 0o600)) + + path := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(` +cache: + enabled: true + external_server: "http://cache.invalid/" + external_secret_file: "`+secretPath+`" +`), 0o600)) + + _, err := LoadDefault(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "contains no secret") +}