diff --git a/cmd/server/flags.go b/cmd/server/flags.go
index 922fdb8ff18..e0594247898 100644
--- a/cmd/server/flags.go
+++ b/cmd/server/flags.go
@@ -289,6 +289,16 @@ var flags = append([]cli.Flag{
Name: "registry-extension-endpoint",
Usage: "url used for calling registry service endpoint",
},
+ &cli.StringFlag{
+ Sources: cli.EnvVars("WOODPECKER_SECRET_EXTENSION_ENDPOINT"),
+ Name: "secret-extension-endpoint",
+ Usage: "url used for calling external secret service endpoint",
+ },
+ &cli.BoolFlag{
+ Sources: cli.EnvVars("WOODPECKER_SECRET_EXTENSION_NETRC"),
+ Name: "secret-extension-netrc",
+ Usage: "include netrc credentials in requests to secret service endpoint",
+ },
&cli.StringFlag{
Sources: cli.EnvVars("WOODPECKER_EXTENSIONS_ALLOWED_HOSTS"),
Name: "extensions-allowed-hosts",
diff --git a/cmd/server/openapi/docs.go b/cmd/server/openapi/docs.go
index 8482ed39545..8bf2e041010 100644
--- a/cmd/server/openapi/docs.go
+++ b/cmd/server/openapi/docs.go
@@ -5292,6 +5292,12 @@ const docTemplate = `{
"require_approval": {
"$ref": "#/definitions/model.ApprovalMode"
},
+ "secret_extension_endpoint": {
+ "type": "string"
+ },
+ "secret_extension_netrc": {
+ "type": "boolean"
+ },
"timeout": {
"type": "integer"
},
@@ -5394,6 +5400,12 @@ const docTemplate = `{
"require_approval": {
"$ref": "#/definitions/model.ApprovalMode"
},
+ "secret_extension_endpoint": {
+ "type": "string"
+ },
+ "secret_extension_netrc": {
+ "type": "boolean"
+ },
"timeout": {
"type": "integer"
},
@@ -5447,6 +5459,12 @@ const docTemplate = `{
"require_approval": {
"type": "string"
},
+ "secret_extension_endpoint": {
+ "type": "string"
+ },
+ "secret_extension_netrc": {
+ "type": "boolean"
+ },
"timeout": {
"type": "integer"
},
diff --git a/docs/docs/20-usage/72-extensions/55-secret-extension.md b/docs/docs/20-usage/72-extensions/55-secret-extension.md
new file mode 100644
index 00000000000..78f46b65105
--- /dev/null
+++ b/docs/docs/20-usage/72-extensions/55-secret-extension.md
@@ -0,0 +1,166 @@
+# Secret extension
+
+Woodpecker uses the secret extension to get secrets from an external service. You can configure an HTTP endpoint in the repository settings in the extensions tab.
+
+Using such an extension can be useful if you want to:
+
+- Centralize secret management (e.g. HashiCorp Vault, AWS Secrets Manager)
+- Dynamically generate secrets per pipeline
+
+## Security
+
+:::warning
+As Woodpecker will pass private information like tokens and will execute the returned configuration, it is extremely important to secure the external extension. Therefore Woodpecker signs every request. Read more about it in the security section.
+:::
+
+## Global configuration
+
+In addition to the ability to configure the extension per repository, you can also configure a global endpoint in the Woodpecker server configuration. This can be useful if you want to use the extension for all repositories. Be careful if
+you share your Woodpecker server with others as they will also use your secret extension.
+
+If both the global and the repo-level extension return a secret with the same name, it will use the secret from the repo extension.
+
+```ini title="Server"
+WOODPECKER_SECRET_EXTENSION_ENDPOINT=https://example.com/secrets
+WOODPECKER_SECRET_EXTENSION_NETRC=false
+```
+
+## How it works
+
+When a pipeline is triggered, Woodpecker will fetch secrets from your service. The extension secrets are merged with the secrets configured directly in Woodpecker, with extension secrets taking priority by name. If the extension is unavailable, Woodpecker falls back to the locally configured secrets.
+
+### Request
+
+The extension receives an HTTP POST request with the following JSON payload:
+
+:::info
+The `netrc` field is only included in the request when the global `WOODPECKER_SECRET_EXTENSION_NETRC` is set to `true` (default: `false`) or the per-repo "Send netrc credentials" is checked.
+:::
+
+```ts
+class Request {
+ repo: Repo;
+ pipeline: Pipeline;
+ netrc?: Netrc; // only included when netrc sending is enabled (see above)
+}
+```
+
+Checkout the following models for more information:
+
+- [repo model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/repo.go)
+- [pipeline model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/pipeline.go)
+- [netrc model](https://github.com/woodpecker-ci/woodpecker/blob/main/server/model/netrc.go)
+
+:::tip
+The `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge (Github or Gitlab, ...) API to get more information about the repository.
+:::
+
+Example request:
+
+```json
+// Please check the latest structure in the models mentioned above.
+// This example is likely outdated.
+
+{
+ "repo": {
+ "id": 100,
+ "uid": "",
+ "user_id": 0,
+ "namespace": "",
+ "name": "woodpecker-test-pipeline",
+ "slug": "",
+ "scm": "git",
+ "git_http_url": "",
+ "git_ssh_url": "",
+ "link": "",
+ "default_branch": "",
+ "private": true,
+ "visibility": "private",
+ "active": true,
+ "config": "",
+ "trusted": false,
+ "protected": false,
+ "ignore_forks": false,
+ "ignore_pulls": false,
+ "cancel_pulls": false,
+ "timeout": 60,
+ "counter": 0,
+ "synced": 0,
+ "created": 0,
+ "updated": 0,
+ "version": 0
+ },
+ "pipeline": {
+ "author": "myUser",
+ "author_avatar": "https://myforge.com/avatars/d6b3f7787a685fcdf2a44e2c685c7e03",
+ "author_email": "my@email.com",
+ "branch": "main",
+ "changed_files": ["some-filename.txt"],
+ "commit": "2fff90f8d288a4640e90f05049fe30e61a14fd50",
+ "created_at": 0,
+ "deploy_to": "",
+ "enqueued_at": 0,
+ "error": "",
+ "event": "push",
+ "finished_at": 0,
+ "id": 0,
+ "link_url": "https://myforge.com/myUser/woodpecker-testpipe/commit/2fff90f8d288a4640e90f05049fe30e61a14fd50",
+ "message": "test old config\n",
+ "number": 0,
+ "parent": 0,
+ "ref": "refs/heads/main",
+ "refspec": "",
+ "clone_url": "",
+ "reviewed_at": 0,
+ "reviewed_by": "",
+ "sender": "myUser",
+ "signed": false,
+ "started_at": 0,
+ "status": "",
+ "timestamp": 1645962783,
+ "title": "",
+ "updated_at": 0,
+ "verified": false
+ },
+ "netrc": {
+ "machine": "myforge.com",
+ "login": "myUser",
+ "password": "forge-access-token"
+ }
+}
+// Note: the "netrc" field is omitted when netrc sending is not enabled.
+```
+
+### Response
+
+The extension should respond with a JSON object containing a `secrets` array.
+If the extension wants to keep the existing secrets without adding any, it can respond with HTTP status `204 No Content`.
+
+```ts
+class Response {
+ secrets: {
+ name: string; // the secret name, matched by from_secret in pipeline config
+ value: string; // the secret value
+ images?: string[]; // optional: restrict to specific plugins
+ events?: string[]; // optional: restrict to specific pipeline events
+ }[];
+}
+```
+
+Example response:
+
+```json
+{
+ "secrets": [
+ {
+ "name": "docker_password",
+ "value": "your-secret-password-123"
+ },
+ {
+ "name": "deploy_token",
+ "value": "super-secret-token",
+ "events": ["push", "tag"]
+ }
+ ]
+}
+```
diff --git a/docs/docs/20-usage/72-extensions/index.md b/docs/docs/20-usage/72-extensions/index.md
index d0cd7134c9e..4f66dd288ff 100644
--- a/docs/docs/20-usage/72-extensions/index.md
+++ b/docs/docs/20-usage/72-extensions/index.md
@@ -6,6 +6,7 @@ There is currently one type of extension available:
- [Configuration extension](./40-configuration-extension.md) to modify or generate pipeline configurations on the fly.
- [Registry extension](./50-registry-extension.md) to get registry credentials from the extension.
+- [Secret extension](./55-secret-extension.md) to get secrets from an external service.
## Security
diff --git a/docs/docs/30-administration/10-configuration/10-server.md b/docs/docs/30-administration/10-configuration/10-server.md
index eab2b338a49..c18a2836e2a 100644
--- a/docs/docs/30-administration/10-configuration/10-server.md
+++ b/docs/docs/30-administration/10-configuration/10-server.md
@@ -993,6 +993,28 @@ If you enable this, all repos will exclusively use the global config service end
---
+### SECRET_EXTENSION_ENDPOINT
+
+- Name: `WOODPECKER_SECRET_EXTENSION_ENDPOINT`
+- Default: none
+
+Specify a secret extension endpoint, see [Secret Extension](../../20-usage/72-extensions/55-secret-extension.md)
+
+---
+
+### SECRET_EXTENSION_NETRC
+
+- Name: `WOODPECKER_SECRET_EXTENSION_NETRC`
+- Default: false
+
+Send `netrc` to the secret extension endpoint.
+
+:::warning
+The `netrc` data is pretty powerful as it contains credentials to access the repository. You can use this to clone the repository or even use the forge API to get more information about the repository.
+:::
+
+---
+
### REGISTRY_EXTENSION_ENDPOINT
- Name: `WOODPECKER_REGISTRY_EXTENSION_ENDPOINT`
diff --git a/server/api/hook_test.go b/server/api/hook_test.go
index e062c8ec9b6..dafabee7ffc 100644
--- a/server/api/hook_test.go
+++ b/server/api/hook_test.go
@@ -97,7 +97,7 @@ func TestHook(t *testing.T) {
_forge.On("Netrc", mock.Anything, mock.Anything).Return(&model.Netrc{}, nil)
_store.On("GetPipelineLastBefore", mock.Anything, mock.Anything, mock.Anything).Return(nil, nil)
_manager.On("SecretServiceFromRepo", repo).Return(_secretService)
- _secretService.On("SecretListPipeline", repo, mock.Anything, mock.Anything).Return(nil, nil)
+ _secretService.On("SecretListPipeline", mock.Anything, repo, mock.Anything, mock.Anything).Return(nil, nil)
_manager.On("RegistryServiceFromRepo", repo).Return(_registryService)
_registryService.On("RegistryListPipeline", mock.Anything, repo, mock.Anything).Return(nil, nil)
_manager.On("EnvironmentService").Return(nil)
diff --git a/server/api/pipeline_test.go b/server/api/pipeline_test.go
index eaa8d7d8db0..5c4d9fad48d 100644
--- a/server/api/pipeline_test.go
+++ b/server/api/pipeline_test.go
@@ -304,7 +304,7 @@ func TestCreatePipeline(t *testing.T) {
}, nil).Maybe()
mockForge.On("Status", mock.Anything, fakeUser, fakeRepo, mock.Anything, mock.Anything).Return(nil).Maybe()
- mockSecretService.On("SecretListPipeline", fakeRepo, mock.Anything).Return([]*model.Secret{}, nil).Maybe()
+ mockSecretService.On("SecretListPipeline", mock.Anything, fakeRepo, mock.Anything, mock.Anything).Return([]*model.Secret{}, nil).Maybe()
mockRegistryService.On("RegistryListPipeline", mock.Anything, fakeRepo, mock.Anything).Return([]*model.Registry{}, nil).Maybe()
mockManager := manager_mocks.NewMockManager(t)
@@ -377,7 +377,7 @@ func TestCreatePipeline(t *testing.T) {
}, nil).Maybe()
mockForge.On("Status", mock.Anything, fakeUser, fakeRepo, mock.Anything, mock.Anything).Return(nil).Maybe()
- mockSecretService.On("SecretListPipeline", fakeRepo, mock.Anything).Return([]*model.Secret{}, nil).Maybe()
+ mockSecretService.On("SecretListPipeline", mock.Anything, fakeRepo, mock.Anything, mock.Anything).Return([]*model.Secret{}, nil).Maybe()
mockRegistryService.On("RegistryListPipeline", mock.Anything, fakeRepo, mock.Anything).Return([]*model.Registry{}, nil).Maybe()
mockManager := manager_mocks.NewMockManager(t)
diff --git a/server/api/repo.go b/server/api/repo.go
index 4fd8a3c758c..d1cf3d2e75d 100644
--- a/server/api/repo.go
+++ b/server/api/repo.go
@@ -299,6 +299,12 @@ func PatchRepo(c *gin.Context) {
if in.RegistryExtensionEndpoint != nil {
repo.RegistryExtensionEndpoint = *in.RegistryExtensionEndpoint
}
+ if in.SecretExtensionEndpoint != nil {
+ repo.SecretExtensionEndpoint = *in.SecretExtensionEndpoint
+ }
+ if in.SecretExtensionNetrc != nil {
+ repo.SecretExtensionNetrc = *in.SecretExtensionNetrc
+ }
err := _store.UpdateRepo(repo)
if err != nil {
diff --git a/server/model/repo.go b/server/model/repo.go
index 96891fb9ded..1c9672582c8 100644
--- a/server/model/repo.go
+++ b/server/model/repo.go
@@ -75,6 +75,8 @@ type Repo struct {
ConfigExtensionEndpoint string `json:"config_extension_endpoint" xorm:"varchar(500) 'config_extension_endpoint'"`
ConfigExtensionExclusive bool `json:"config_extension_exclusive" xorm:"DEFAULT FALSE 'config_extension_exclusive'"`
RegistryExtensionEndpoint string `json:"registry_extension_endpoint" xorm:"varchar(500) 'registry_extension_endpoint'"`
+ SecretExtensionEndpoint string `json:"secret_extension_endpoint" xorm:"varchar(500) 'secret_extension_endpoint'"`
+ SecretExtensionNetrc bool `json:"secret_extension_netrc" xorm:"DEFAULT FALSE 'secret_extension_netrc'"`
} // @name Repo
// TableName return database table name for xorm.
@@ -148,6 +150,8 @@ type RepoPatch struct {
ConfigExtensionEndpoint *string `json:"config_extension_endpoint,omitempty"`
ConfigExtensionExclusive *bool `json:"config_extension_exclusive"`
RegistryExtensionEndpoint *string `json:"registry_extension_endpoint,omitempty"`
+ SecretExtensionEndpoint *string `json:"secret_extension_endpoint,omitempty"`
+ SecretExtensionNetrc *bool `json:"secret_extension_netrc,omitempty"`
} // @name RepoPatch
type ForgeRemoteID string
diff --git a/server/pipeline/items.go b/server/pipeline/items.go
index 5eb1cf189a8..7791541ecf5 100644
--- a/server/pipeline/items.go
+++ b/server/pipeline/items.go
@@ -46,7 +46,7 @@ func parsePipeline(ctx context.Context, forge forge.Forge, store store.Store, cu
}
secretService := server.Config.Services.Manager.SecretServiceFromRepo(repo)
- secs, err := secretService.SecretListPipeline(repo, currentPipeline)
+ secs, err := secretService.SecretListPipeline(ctx, repo, currentPipeline, netrc)
if err != nil {
log.Error().Err(err).Msgf("error getting secrets for %s#%d", repo.FullName, currentPipeline.Number)
}
diff --git a/server/pipeline/items_test.go b/server/pipeline/items_test.go
index 5bb61207c54..cdec0f62af5 100644
--- a/server/pipeline/items_test.go
+++ b/server/pipeline/items_test.go
@@ -132,7 +132,7 @@ steps:
server.Config.Services.Manager = mockManager
secretService := secret_service_mocks.NewMockService(t)
- secretService.On("SecretListPipeline", mock.Anything, mock.Anything).Return([]*model.Secret{
+ secretService.On("SecretListPipeline", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]*model.Secret{
{
Name: "hello",
Value: "secret world",
diff --git a/server/services/manager.go b/server/services/manager.go
index ca7a1b304bf..50a91d61c4b 100644
--- a/server/services/manager.go
+++ b/server/services/manager.go
@@ -87,7 +87,7 @@ func NewManager(c *cli.Command, store store.Store, setupForge SetupForge) (Manag
signaturePrivateKey: signaturePrivateKey,
signaturePublicKey: signaturePublicKey,
store: store,
- secret: setupSecretService(store),
+ secret: setupSecretService(store, c.String("secret-extension-endpoint"), client, c.Bool("secret-extension-netrc")),
registry: setupRegistryService(store, c.String("docker-config"), c.String("registry-extension-endpoint"), client),
config: configService,
environment: environment.Parse(c.StringSlice("environment")),
@@ -101,7 +101,11 @@ func (m *manager) SignaturePublicKey() crypto.PublicKey {
return m.signaturePublicKey
}
-func (m *manager) SecretServiceFromRepo(_ *model.Repo) secret.Service {
+func (m *manager) SecretServiceFromRepo(repo *model.Repo) secret.Service {
+ if repo.SecretExtensionEndpoint != "" {
+ return secret.NewCombined(m.secret, secret.NewHTTP(strings.TrimRight(repo.SecretExtensionEndpoint, "/"), m.client, repo.SecretExtensionNetrc))
+ }
+
return m.SecretService()
}
diff --git a/server/services/secret/combined.go b/server/services/secret/combined.go
new file mode 100644
index 00000000000..e7b594679b9
--- /dev/null
+++ b/server/services/secret/combined.go
@@ -0,0 +1,136 @@
+// Copyright 2026 Woodpecker Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package secret
+
+import (
+ "context"
+
+ "github.com/rs/zerolog/log"
+
+ "go.woodpecker-ci.org/woodpecker/v3/server/model"
+)
+
+type combined struct {
+ base Service
+ extension *httpExtension
+}
+
+// NewCombined returns a secret service that combines a base service with an HTTP extension.
+// The extension is called during SecretListPipeline to fetch additional secrets and
+// the extension secrets taking priority.
+func NewCombined(base Service, extension *httpExtension) Service {
+ return &combined{base, extension}
+}
+
+func (c *combined) SecretListPipeline(ctx context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Secret, error) {
+ // Get secrets from base service
+ baseSecrets, err := c.base.SecretListPipeline(ctx, repo, pipeline, netrc)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get secrets from HTTP extension
+ extensionSecrets, err := c.extension.SecretListPipeline(ctx, repo, pipeline, netrc)
+ if err != nil {
+ // Log the error but don't fail - use base secrets only
+ log.Warn().Err(err).Msg("failed to fetch secrets from extension")
+ return baseSecrets, nil
+ }
+
+ if len(extensionSecrets) == 0 {
+ return baseSecrets, nil
+ }
+
+ // Merge secrets, with extension secrets taking priority (no duplicates by name)
+ exists := make(map[string]struct{}, len(extensionSecrets))
+ for _, s := range extensionSecrets {
+ exists[s.Name] = struct{}{}
+ }
+
+ merged := make([]*model.Secret, 0, len(baseSecrets)+len(extensionSecrets))
+ merged = append(merged, extensionSecrets...)
+
+ for _, s := range baseSecrets {
+ if _, ok := exists[s.Name]; ok {
+ continue
+ }
+ exists[s.Name] = struct{}{}
+ merged = append(merged, s)
+ }
+
+ return merged, nil
+}
+
+// All other methods delegate to the base service.
+
+func (c *combined) SecretFind(repo *model.Repo, name string) (*model.Secret, error) {
+ return c.base.SecretFind(repo, name)
+}
+
+func (c *combined) SecretList(repo *model.Repo, p *model.ListOptions) ([]*model.Secret, error) {
+ return c.base.SecretList(repo, p)
+}
+
+func (c *combined) SecretCreate(repo *model.Repo, secret *model.Secret) error {
+ return c.base.SecretCreate(repo, secret)
+}
+
+func (c *combined) SecretUpdate(repo *model.Repo, secret *model.Secret) error {
+ return c.base.SecretUpdate(repo, secret)
+}
+
+func (c *combined) SecretDelete(repo *model.Repo, name string) error {
+ return c.base.SecretDelete(repo, name)
+}
+
+func (c *combined) OrgSecretFind(orgID int64, name string) (*model.Secret, error) {
+ return c.base.OrgSecretFind(orgID, name)
+}
+
+func (c *combined) OrgSecretList(orgID int64, p *model.ListOptions) ([]*model.Secret, error) {
+ return c.base.OrgSecretList(orgID, p)
+}
+
+func (c *combined) OrgSecretCreate(orgID int64, secret *model.Secret) error {
+ return c.base.OrgSecretCreate(orgID, secret)
+}
+
+func (c *combined) OrgSecretUpdate(orgID int64, secret *model.Secret) error {
+ return c.base.OrgSecretUpdate(orgID, secret)
+}
+
+func (c *combined) OrgSecretDelete(orgID int64, name string) error {
+ return c.base.OrgSecretDelete(orgID, name)
+}
+
+func (c *combined) GlobalSecretFind(name string) (*model.Secret, error) {
+ return c.base.GlobalSecretFind(name)
+}
+
+func (c *combined) GlobalSecretList(p *model.ListOptions) ([]*model.Secret, error) {
+ return c.base.GlobalSecretList(p)
+}
+
+func (c *combined) GlobalSecretCreate(secret *model.Secret) error {
+ return c.base.GlobalSecretCreate(secret)
+}
+
+func (c *combined) GlobalSecretUpdate(secret *model.Secret) error {
+ return c.base.GlobalSecretUpdate(secret)
+}
+
+func (c *combined) GlobalSecretDelete(name string) error {
+ return c.base.GlobalSecretDelete(name)
+}
diff --git a/server/services/secret/combined_test.go b/server/services/secret/combined_test.go
new file mode 100644
index 00000000000..b522773e31d
--- /dev/null
+++ b/server/services/secret/combined_test.go
@@ -0,0 +1,173 @@
+// Copyright 2026 Woodpecker Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package secret_test
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+ "github.com/yaronf/httpsign"
+
+ "go.woodpecker-ci.org/woodpecker/v3/server/model"
+ "go.woodpecker-ci.org/woodpecker/v3/server/services/secret"
+ "go.woodpecker-ci.org/woodpecker/v3/server/services/utils"
+ store_mocks "go.woodpecker-ci.org/woodpecker/v3/server/store/mocks"
+)
+
+func TestCombinedSecretListPipeline(t *testing.T) {
+ t.Parallel()
+
+ testTable := []struct {
+ name string
+ repoName string
+ dbSecrets []*model.Secret
+ expected []*model.Secret
+ expectedError bool
+ }{
+ {
+ name: "Extension overrides base secret by name",
+ repoName: "override-test",
+ dbSecrets: []*model.Secret{
+ {ID: 1, RepoID: 1, Name: "shared", Value: "db-value"},
+ {ID: 2, RepoID: 1, Name: "db-only", Value: "only-in-db"},
+ },
+ expected: []*model.Secret{
+ {Name: "shared", Value: "external-value"},
+ {Name: "ext-only", Value: "only-in-ext"},
+ {ID: 2, RepoID: 1, Name: "db-only", Value: "only-in-db"},
+ },
+ expectedError: false,
+ },
+ {
+ name: "Extension returns 204 no secrets",
+ repoName: "no-content",
+ dbSecrets: []*model.Secret{
+ {ID: 1, RepoID: 1, Name: "db-secret", Value: "db-value"},
+ },
+ expected: []*model.Secret{
+ {ID: 1, RepoID: 1, Name: "db-secret", Value: "db-value"},
+ },
+ expectedError: false,
+ },
+ {
+ name: "Extension error falls back to base secrets",
+ repoName: "server-error",
+ dbSecrets: []*model.Secret{
+ {ID: 1, RepoID: 1, Name: "db-secret", Value: "db-value"},
+ },
+ expected: []*model.Secret{
+ {ID: 1, RepoID: 1, Name: "db-secret", Value: "db-value"},
+ },
+ expectedError: false,
+ },
+ }
+
+ pubEd25519Key, privEd25519Key, err := ed25519.GenerateKey(rand.Reader)
+ require.NoError(t, err, "can't generate ed25519 keypair")
+
+ fixtureHandler := func(w http.ResponseWriter, r *http.Request) {
+ // check signature
+ pubKeyID := "woodpecker-ci-extensions"
+
+ verifier, err := httpsign.NewEd25519Verifier(pubEd25519Key,
+ httpsign.NewVerifyConfig(),
+ httpsign.Headers("@request-target", "content-digest"))
+ if err != nil {
+ http.Error(w, "can't create verifier", http.StatusInternalServerError)
+ return
+ }
+
+ err = httpsign.VerifyRequest(pubKeyID, *verifier, r)
+ if err != nil {
+ http.Error(w, "Invalid signature", http.StatusBadRequest)
+ return
+ }
+
+ type incoming struct {
+ Repo *model.Repo `json:"repo"`
+ Pipeline *model.Pipeline `json:"pipeline"`
+ Netrc *model.Netrc `json:"netrc"`
+ }
+
+ var req incoming
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ http.Error(w, "can't read body", http.StatusBadRequest)
+ return
+ }
+ err = json.Unmarshal(body, &req)
+ if err != nil {
+ http.Error(w, "Failed to parse JSON"+err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ switch req.Repo.Name {
+ case "no-content":
+ w.WriteHeader(http.StatusNoContent)
+ return
+ case "server-error":
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{
+ "secrets": []*model.Secret{
+ {Name: "shared", Value: "external-value"},
+ {Name: "ext-only", Value: "only-in-ext"},
+ },
+ }))
+ }
+
+ ts := httptest.NewServer(http.HandlerFunc(fixtureHandler))
+ defer ts.Close()
+
+ client, err := utils.NewHTTPClient(privEd25519Key, "loopback")
+ require.NoError(t, err)
+
+ httpExtension := secret.NewHTTP(ts.URL, client, false)
+
+ for _, tt := range testTable {
+ t.Run(tt.name, func(t *testing.T) {
+ mockStore := store_mocks.NewMockStore(t)
+ mockStore.On("SecretList", mock.Anything, true, mock.Anything).Return(tt.dbSecrets, nil)
+
+ combined := secret.NewCombined(secret.NewDB(mockStore), httpExtension)
+
+ secrets, err := combined.SecretListPipeline(
+ t.Context(),
+ &model.Repo{ID: 1, Name: tt.repoName},
+ &model.Pipeline{},
+ nil,
+ )
+ if tt.expectedError {
+ require.Error(t, err, "expected an error")
+ } else {
+ require.NoError(t, err, "error fetching secrets")
+ }
+
+ assert.ElementsMatch(t, tt.expected, secrets, "expected some other secrets")
+ })
+ }
+}
diff --git a/server/services/secret/db.go b/server/services/secret/db.go
index 25e779e3d8e..66303ec576b 100644
--- a/server/services/secret/db.go
+++ b/server/services/secret/db.go
@@ -15,6 +15,8 @@
package secret
import (
+ "context"
+
"go.woodpecker-ci.org/woodpecker/v3/server/model"
"go.woodpecker-ci.org/woodpecker/v3/server/store"
)
@@ -36,7 +38,7 @@ func (d *db) SecretList(repo *model.Repo, p *model.ListOptions) ([]*model.Secret
return d.store.SecretList(repo, false, p)
}
-func (d *db) SecretListPipeline(repo *model.Repo, _ *model.Pipeline) ([]*model.Secret, error) {
+func (d *db) SecretListPipeline(_ context.Context, repo *model.Repo, _ *model.Pipeline, _ *model.Netrc) ([]*model.Secret, error) {
s, err := d.store.SecretList(repo, true, &model.ListOptions{All: true})
if err != nil {
return nil, err
diff --git a/server/services/secret/db_test.go b/server/services/secret/db_test.go
index 433b3e88699..b75aa44aec5 100644
--- a/server/services/secret/db_test.go
+++ b/server/services/secret/db_test.go
@@ -60,7 +60,7 @@ func TestSecretListPipeline(t *testing.T) {
repoSecret,
}, nil)
- s, err := secret.NewDB(mockStore).SecretListPipeline(&model.Repo{}, &model.Pipeline{})
+ s, err := secret.NewDB(mockStore).SecretListPipeline(t.Context(), &model.Repo{}, &model.Pipeline{}, nil)
assert.NoError(t, err)
assert.Len(t, s, 1)
@@ -71,7 +71,7 @@ func TestSecretListPipeline(t *testing.T) {
orgSecret,
}, nil)
- s, err = secret.NewDB(mockStore).SecretListPipeline(&model.Repo{}, &model.Pipeline{})
+ s, err = secret.NewDB(mockStore).SecretListPipeline(t.Context(), &model.Repo{}, &model.Pipeline{}, nil)
assert.NoError(t, err)
assert.Len(t, s, 1)
@@ -81,7 +81,7 @@ func TestSecretListPipeline(t *testing.T) {
globalSecret,
}, nil)
- s, err = secret.NewDB(mockStore).SecretListPipeline(&model.Repo{}, &model.Pipeline{})
+ s, err = secret.NewDB(mockStore).SecretListPipeline(t.Context(), &model.Repo{}, &model.Pipeline{}, nil)
assert.NoError(t, err)
assert.Len(t, s, 1)
diff --git a/server/services/secret/http.go b/server/services/secret/http.go
new file mode 100644
index 00000000000..e4293c1798e
--- /dev/null
+++ b/server/services/secret/http.go
@@ -0,0 +1,69 @@
+// Copyright 2026 Woodpecker Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package secret
+
+import (
+ "context"
+ "fmt"
+ net_http "net/http"
+
+ "go.woodpecker-ci.org/woodpecker/v3/server/model"
+ "go.woodpecker-ci.org/woodpecker/v3/server/services/utils"
+)
+
+type httpExtension struct {
+ endpoint string
+ client *utils.Client
+ includeNetrc bool
+}
+
+type secretRequestStructure struct {
+ Repo *model.Repo `json:"repo"`
+ Pipeline *model.Pipeline `json:"pipeline"`
+ Netrc *model.Netrc `json:"netrc,omitempty"`
+}
+
+type secretResponseStructure struct {
+ Secrets []*model.Secret `json:"secrets"`
+}
+
+// NewHTTP returns a new HTTP secret extension client.
+func NewHTTP(endpoint string, client *utils.Client, includeNetrc bool) *httpExtension {
+ return &httpExtension{endpoint: endpoint, client: client, includeNetrc: includeNetrc}
+}
+
+// SecretListPipeline fetches secrets from an external HTTP extension.
+func (h *httpExtension) SecretListPipeline(ctx context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Secret, error) {
+ body := secretRequestStructure{
+ Repo: repo,
+ Pipeline: pipeline,
+ }
+ if h.includeNetrc {
+ body.Netrc = netrc
+ }
+
+ response := new(secretResponseStructure)
+ status, err := h.client.Send(ctx, net_http.MethodPost, h.endpoint, body, response)
+ if err != nil && status != net_http.StatusNoContent {
+ return nil, fmt.Errorf("failed to fetch secrets via http (%d) %w", status, err)
+ }
+
+ if status != net_http.StatusOK {
+ // 204 No Content means no additional secrets
+ return nil, nil
+ }
+
+ return response.Secrets, nil
+}
diff --git a/server/services/secret/mocks/mock_Service.go b/server/services/secret/mocks/mock_Service.go
index 38d15e16d32..c4aa4711f9d 100644
--- a/server/services/secret/mocks/mock_Service.go
+++ b/server/services/secret/mocks/mock_Service.go
@@ -5,6 +5,8 @@
package mocks
import (
+ "context"
+
mock "github.com/stretchr/testify/mock"
"go.woodpecker-ci.org/woodpecker/v3/server/model"
)
@@ -871,8 +873,8 @@ func (_c *MockService_SecretList_Call) RunAndReturn(run func(repo *model.Repo, l
}
// SecretListPipeline provides a mock function for the type MockService
-func (_mock *MockService) SecretListPipeline(repo *model.Repo, pipeline *model.Pipeline) ([]*model.Secret, error) {
- ret := _mock.Called(repo, pipeline)
+func (_mock *MockService) SecretListPipeline(context1 context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Secret, error) {
+ ret := _mock.Called(context1, repo, pipeline, netrc)
if len(ret) == 0 {
panic("no return value specified for SecretListPipeline")
@@ -880,18 +882,18 @@ func (_mock *MockService) SecretListPipeline(repo *model.Repo, pipeline *model.P
var r0 []*model.Secret
var r1 error
- if returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.Pipeline) ([]*model.Secret, error)); ok {
- return returnFunc(repo, pipeline)
+ if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) ([]*model.Secret, error)); ok {
+ return returnFunc(context1, repo, pipeline, netrc)
}
- if returnFunc, ok := ret.Get(0).(func(*model.Repo, *model.Pipeline) []*model.Secret); ok {
- r0 = returnFunc(repo, pipeline)
+ if returnFunc, ok := ret.Get(0).(func(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) []*model.Secret); ok {
+ r0 = returnFunc(context1, repo, pipeline, netrc)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.Secret)
}
}
- if returnFunc, ok := ret.Get(1).(func(*model.Repo, *model.Pipeline) error); ok {
- r1 = returnFunc(repo, pipeline)
+ if returnFunc, ok := ret.Get(1).(func(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) error); ok {
+ r1 = returnFunc(context1, repo, pipeline, netrc)
} else {
r1 = ret.Error(1)
}
@@ -904,25 +906,37 @@ type MockService_SecretListPipeline_Call struct {
}
// SecretListPipeline is a helper method to define mock.On call
+// - context1 context.Context
// - repo *model.Repo
// - pipeline *model.Pipeline
-func (_e *MockService_Expecter) SecretListPipeline(repo interface{}, pipeline interface{}) *MockService_SecretListPipeline_Call {
- return &MockService_SecretListPipeline_Call{Call: _e.mock.On("SecretListPipeline", repo, pipeline)}
+// - netrc *model.Netrc
+func (_e *MockService_Expecter) SecretListPipeline(context1 interface{}, repo interface{}, pipeline interface{}, netrc interface{}) *MockService_SecretListPipeline_Call {
+ return &MockService_SecretListPipeline_Call{Call: _e.mock.On("SecretListPipeline", context1, repo, pipeline, netrc)}
}
-func (_c *MockService_SecretListPipeline_Call) Run(run func(repo *model.Repo, pipeline *model.Pipeline)) *MockService_SecretListPipeline_Call {
+func (_c *MockService_SecretListPipeline_Call) Run(run func(context1 context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc)) *MockService_SecretListPipeline_Call {
_c.Call.Run(func(args mock.Arguments) {
- var arg0 *model.Repo
+ var arg0 context.Context
if args[0] != nil {
- arg0 = args[0].(*model.Repo)
+ arg0 = args[0].(context.Context)
}
- var arg1 *model.Pipeline
+ var arg1 *model.Repo
if args[1] != nil {
- arg1 = args[1].(*model.Pipeline)
+ arg1 = args[1].(*model.Repo)
+ }
+ var arg2 *model.Pipeline
+ if args[2] != nil {
+ arg2 = args[2].(*model.Pipeline)
+ }
+ var arg3 *model.Netrc
+ if args[3] != nil {
+ arg3 = args[3].(*model.Netrc)
}
run(
arg0,
arg1,
+ arg2,
+ arg3,
)
})
return _c
@@ -933,7 +947,7 @@ func (_c *MockService_SecretListPipeline_Call) Return(secrets []*model.Secret, e
return _c
}
-func (_c *MockService_SecretListPipeline_Call) RunAndReturn(run func(repo *model.Repo, pipeline *model.Pipeline) ([]*model.Secret, error)) *MockService_SecretListPipeline_Call {
+func (_c *MockService_SecretListPipeline_Call) RunAndReturn(run func(context1 context.Context, repo *model.Repo, pipeline *model.Pipeline, netrc *model.Netrc) ([]*model.Secret, error)) *MockService_SecretListPipeline_Call {
_c.Call.Return(run)
return _c
}
diff --git a/server/services/secret/service.go b/server/services/secret/service.go
index c2f5c97589c..46586cde3ab 100644
--- a/server/services/secret/service.go
+++ b/server/services/secret/service.go
@@ -14,11 +14,15 @@
package secret
-import "go.woodpecker-ci.org/woodpecker/v3/server/model"
+import (
+ "context"
+
+ "go.woodpecker-ci.org/woodpecker/v3/server/model"
+)
// Service defines a service for managing secrets.
type Service interface {
- SecretListPipeline(*model.Repo, *model.Pipeline) ([]*model.Secret, error)
+ SecretListPipeline(context.Context, *model.Repo, *model.Pipeline, *model.Netrc) ([]*model.Secret, error)
// Repository secrets
SecretFind(*model.Repo, string) (*model.Secret, error)
SecretList(*model.Repo, *model.ListOptions) ([]*model.Secret, error)
diff --git a/server/services/setup.go b/server/services/setup.go
index 196cd759ef7..5a2d40b2f14 100644
--- a/server/services/setup.go
+++ b/server/services/setup.go
@@ -54,7 +54,7 @@ func setupRegistryService(store store.Store, dockerConfig, endpoint string, clie
return service
}
-func setupSecretService(store store.Store) secret.Service {
+func setupSecretService(store store.Store, endpoint string, client *utils.Client, includeNetrc bool) secret.Service {
// TODO(1544): fix encrypted store
// // encryption
// encryptedSecretStore := encryptedStore.NewSecretStore(v)
@@ -63,6 +63,10 @@ func setupSecretService(store store.Store) secret.Service {
// log.Fatal().Err(err).Msg("could not create encryption service")
// }
+ if endpoint != "" {
+ return secret.NewCombined(secret.NewDB(store), secret.NewHTTP(endpoint, client, includeNetrc))
+ }
+
return secret.NewDB(store)
}
diff --git a/web/src/assets/locales/en.json b/web/src/assets/locales/en.json
index 3930cf4e9d1..274f6fb9def 100644
--- a/web/src/assets/locales/en.json
+++ b/web/src/assets/locales/en.json
@@ -540,6 +540,9 @@
"registry_extension_endpoint": "Registry extension endpoint",
"config_extension_exclusive": "Exclusive",
"config_extension_exclusive_desc": "If enabled, will skip all other ways of configuration fetching, including the forge.",
+ "secret_extension_endpoint": "Secret extension endpoint",
+ "secret_extension_netrc": "Include netrc credentials",
+ "secret_extension_netrc_desc": "Send forge netrc credentials to the secret extension.",
"extensions_signatures_public_key": "Public key for signatures",
"extensions_signatures_public_key_description": "This public key should be used by your extensions to verify webhook calls from Woodpecker.",
"extensions_configuration_saved": "Extensions configuration saved",
diff --git a/web/src/lib/api/types/repo.ts b/web/src/lib/api/types/repo.ts
index 82624c44b5d..a52f0968cbf 100644
--- a/web/src/lib/api/types/repo.ts
+++ b/web/src/lib/api/types/repo.ts
@@ -87,6 +87,12 @@ export interface Repo {
// Endpoint for registry extensions
registry_extension_endpoint: string;
+
+ // Endpoint for secret extensions
+ secret_extension_endpoint: string;
+
+ // Whether to include netrc credentials in secret extension requests
+ secret_extension_netrc: boolean;
}
/* eslint-disable no-unused-vars */
@@ -120,7 +126,11 @@ export type RepoSettings = Pick<
export type ExtensionSettings = Pick<
Repo,
- 'config_extension_endpoint' | 'config_extension_exclusive' | 'registry_extension_endpoint'
+ | 'config_extension_endpoint'
+ | 'config_extension_exclusive'
+ | 'registry_extension_endpoint'
+ | 'secret_extension_endpoint'
+ | 'secret_extension_netrc'
>;
export interface RepoPermissions {
diff --git a/web/src/views/repo/settings/Extensions.vue b/web/src/views/repo/settings/Extensions.vue
index 998b8c90d64..5539e48fe4a 100644
--- a/web/src/views/repo/settings/Extensions.vue
+++ b/web/src/views/repo/settings/Extensions.vue
@@ -25,6 +25,16 @@
/>
+
+
+
+
+
@@ -65,6 +75,8 @@ const extensions = ref({
config_extension_endpoint: repo.value.config_extension_endpoint,
config_extension_exclusive: repo.value.config_extension_exclusive,
registry_extension_endpoint: repo.value.registry_extension_endpoint,
+ secret_extension_endpoint: repo.value.secret_extension_endpoint,
+ secret_extension_netrc: repo.value.secret_extension_netrc,
});
const { doSubmit: saveExtensions, isLoading: isSaving } = useAsyncAction(async () => {